Implement super-sideloader ADI backends and IPA loading
Dadoum

Dadoum commited on 2026-07-08 09:55:10
Showing 31 changed files, with 8797 additions and 1684 deletions.

... ...
@@ -577,6 +577,8 @@ dependencies = [
577 577
  "futures-lite 2.6.1",
578 578
  "pin-project",
579 579
  "thiserror 2.0.18",
580
+ "tokio",
581
+ "tokio-util",
580 582
 ]
581 583
 
582 584
 [[package]]
... ...
@@ -1781,6 +1783,15 @@ dependencies = [
1781 1783
  "ctutils",
1782 1784
 ]
1783 1785
 
1786
+[[package]]
1787
+name = "directories"
1788
+version = "6.0.0"
1789
+source = "registry+https://github.com/rust-lang/crates.io-index"
1790
+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
1791
+dependencies = [
1792
+ "dirs-sys",
1793
+]
1794
+
1784 1795
 [[package]]
1785 1796
 name = "dirs"
1786 1797
 version = "6.0.0"
... ...
@@ -3338,6 +3349,26 @@ dependencies = [
3338 3349
  "zerovec",
3339 3350
 ]
3340 3351
 
3352
+[[package]]
3353
+name = "idevice"
3354
+version = "0.1.64"
3355
+source = "registry+https://github.com/rust-lang/crates.io-index"
3356
+checksum = "48e95040e91503e7425835843e5e985ab1ea440db9b965972021e7b845c2a6ae"
3357
+dependencies = [
3358
+ "base64",
3359
+ "futures",
3360
+ "libc",
3361
+ "plist",
3362
+ "plist-macro",
3363
+ "rand 0.10.2",
3364
+ "rustls",
3365
+ "serde",
3366
+ "thiserror 2.0.18",
3367
+ "tokio",
3368
+ "tokio-rustls",
3369
+ "tracing",
3370
+]
3371
+
3341 3372
 [[package]]
3342 3373
 name = "idna"
3343 3374
 version = "1.1.0"
... ...
@@ -4870,6 +4901,15 @@ dependencies = [
4870 4901
  "time",
4871 4902
 ]
4872 4903
 
4904
+[[package]]
4905
+name = "plist-macro"
4906
+version = "0.1.6"
4907
+source = "registry+https://github.com/rust-lang/crates.io-index"
4908
+checksum = "82c39e020f2d8d361e91a434cfe26bc7c3e21ae54414379816d0110f3f7ca121"
4909
+dependencies = [
4910
+ "plist",
4911
+]
4912
+
4873 4913
 [[package]]
4874 4914
 name = "plist-macros"
4875 4915
 version = "0.1.0"
... ...
@@ -5518,7 +5558,9 @@ dependencies = [
5518 5558
  "base64",
5519 5559
  "bytes",
5520 5560
  "encoding_rs",
5561
+ "futures-channel",
5521 5562
  "futures-core",
5563
+ "futures-util",
5522 5564
  "h2",
5523 5565
  "http",
5524 5566
  "http-body",
... ...
@@ -6477,9 +6519,31 @@ dependencies = [
6477 6519
 name = "super-sideloader"
6478 6520
 version = "0.1.0"
6479 6521
 dependencies = [
6522
+ "adi",
6523
+ "adid-proxy",
6524
+ "android-coreadi",
6525
+ "async_zip",
6526
+ "core-foundation 0.10.0",
6527
+ "directories",
6528
+ "dlopen2",
6480 6529
  "env_logger",
6530
+ "futures",
6531
+ "futures-lite 2.6.1",
6481 6532
  "gpui",
6482 6533
  "gpui_platform",
6534
+ "grandslam",
6535
+ "idevice",
6536
+ "libc",
6537
+ "library-coreadi",
6538
+ "log",
6539
+ "ouroboros",
6540
+ "plist",
6541
+ "rand 0.10.2",
6542
+ "reqwest",
6543
+ "serde",
6544
+ "tokio",
6545
+ "toml 0.8.23",
6546
+ "uuid",
6483 6547
 ]
6484 6548
 
6485 6549
 [[package]]
... ...
@@ -6919,9 +6983,21 @@ dependencies = [
6919 6983
  "mio",
6920 6984
  "pin-project-lite",
6921 6985
  "socket2",
6986
+ "tokio-macros",
6922 6987
  "windows-sys 0.61.2",
6923 6988
 ]
6924 6989
 
6990
+[[package]]
6991
+name = "tokio-macros"
6992
+version = "2.7.0"
6993
+source = "registry+https://github.com/rust-lang/crates.io-index"
6994
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
6995
+dependencies = [
6996
+ "proc-macro2",
6997
+ "quote",
6998
+ "syn",
6999
+]
7000
+
6925 7001
 [[package]]
6926 7002
 name = "tokio-rustls"
6927 7003
 version = "0.26.4"
... ...
@@ -6940,6 +7016,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
6940 7016
 dependencies = [
6941 7017
  "bytes",
6942 7018
  "futures-core",
7019
+ "futures-io",
6943 7020
  "futures-sink",
6944 7021
  "pin-project-lite",
6945 7022
  "tokio",
... ...
@@ -1,4 +1,5 @@
1 1
 #![allow(non_camel_case_types)]
2
+#![allow(dead_code)]
2 3
 
3 4
 #[cfg(target_pointer_width = "32")]
4 5
 #[repr(C)]
... ...
@@ -4,6 +4,32 @@ version = "0.1.0"
4 4
 edition = "2021"
5 5
 
6 6
 [dependencies]
7
+adi.workspace = true
8
+android-coreadi.workspace = true
9
+async_zip = { version = "0.0.18", features = ["deflate", "tokio-fs"] }
10
+directories = "6.0.0"
7 11
 env_logger = "0.11.8"
12
+futures-lite = "2.6.1"
13
+grandslam.workspace = true
8 14
 gpui = { package = "gpui", git = "https://github.com/gpui-ce/gpui-ce" }
9 15
 gpui_platform = { git = "https://github.com/gpui-ce/gpui-ce", features = ["font-kit", "x11", "wayland", "runtime_shaders"] }
16
+idevice = { version = "0.1.64", features = ["usbmuxd"] }
17
+futures = "0.3"
18
+log = "0.4.33"
19
+plist.workspace = true
20
+rand = "0.10.2"
21
+reqwest = { workspace = true, features = ["blocking"] }
22
+serde = { version = "1.0.228", features = ["derive"] }
23
+toml = "0.8.23"
24
+tokio = { workspace = true, features = ["io-util", "net", "time"] }
25
+uuid = { version = "1.19", features = ["v4"] }
26
+
27
+[target.'cfg(target_os = "macos")'.dependencies]
28
+adid-proxy = { path = "../adid-proxy" }
29
+core-foundation = "0.10.0"
30
+libc = "0.2.176"
31
+
32
+[target.'cfg(target_os = "windows")'.dependencies]
33
+dlopen2 = "0.8.2"
34
+library-coreadi.workspace = true
35
+ouroboros = "0.18.5"
... ...
@@ -0,0 +1,3 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+  <path d="M20 6 9 17l-5-5"/>
3
+</svg>
... ...
@@ -0,0 +1,3 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+  <path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>
3
+</svg>
... ...
@@ -0,0 +1,5 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+  <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
3
+  <polyline points="16 17 21 12 16 7"/>
4
+  <line x1="21" x2="9" y1="12" y2="12"/>
5
+</svg>
... ...
@@ -0,0 +1,4 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+  <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
3
+  <path d="M3 3v5h5"/>
4
+</svg>
... ...
@@ -0,0 +1,4 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+  <path d="M18 6 6 18"/>
3
+  <path d="m6 6 12 12"/>
4
+</svg>
... ...
@@ -0,0 +1,633 @@
1
+use crate::models::{
2
+    AdiBackendAvailability, AdiBackendDetail, AdiBackendKind, AdiBackendOption,
3
+    AdiProvisioningState, AdiRepairAction, MachineIdentity,
4
+};
5
+use crate::paths::app_data_dir;
6
+use adi::core_adi::CoreADIADIProxy;
7
+#[cfg(target_os = "windows")]
8
+use adi::core_adi::{CoreADIParameters, CoreADIProxy};
9
+use adi::ADIProxy;
10
+use async_zip::tokio::read::fs::ZipFileReader;
11
+use futures_lite::io::AsyncReadExt;
12
+use grandslam::bundle_information::BundleInformation;
13
+use grandslam::device::Device;
14
+use grandslam::http_session::AnisetteHTTPSession;
15
+#[cfg(target_os = "windows")]
16
+use ouroboros::self_referencing;
17
+use std::ffi::CString;
18
+use std::fs::{self, File};
19
+use std::io::{Read, Write};
20
+use std::path::{Path, PathBuf};
21
+
22
+const ANDROID_COREADI_APK_URL: &str =
23
+    "https://apps.mzstatic.com/content/android-apple-music-apk/applemusic.apk";
24
+const ANDROID_COREADI_APK_FILE: &str = "applemusic.apk";
25
+const ANDROID_COREADI_LIBRARY_FILE: &str = "libCoreADI.so";
26
+const GRANDSLAM_DSID: i64 = -2;
27
+const XCODE_BUNDLE_INFORMATION: BundleInformation<'static> = BundleInformation {
28
+    bundle_name: "Xcode",
29
+    bundle_identifier: "com.apple.dt.Xcode",
30
+    bundle_version: "23792",
31
+};
32
+
33
+#[derive(Clone, Copy, Debug)]
34
+pub(crate) struct AndroidCoreAdiInstallProgress {
35
+    pub(crate) downloaded_bytes: u64,
36
+    pub(crate) total_bytes: Option<u64>,
37
+}
38
+
39
+#[derive(Clone, Copy, Debug)]
40
+pub(crate) enum AndroidCoreAdiInstallEvent {
41
+    Downloading(AndroidCoreAdiInstallProgress),
42
+    Installing,
43
+}
44
+
45
+pub(crate) fn available_adi_backends(android_adi_identifier: &str) -> Vec<AdiBackendOption> {
46
+    let mut backends: Vec<_> = vec![
47
+        system_adi_backend(),
48
+        windows_coreadi_backend(),
49
+        android_coreadi_backend(android_adi_identifier),
50
+    ]
51
+    .into_iter()
52
+    .filter(|backend| backend.availability.is_available())
53
+    .collect();
54
+
55
+    for backend in &mut backends {
56
+        backend.provisioning_state = if backend.availability.is_ready() {
57
+            adi_provisioning_state(backend.kind, android_adi_identifier)
58
+        } else {
59
+            AdiProvisioningState::NotAvailable
60
+        };
61
+    }
62
+
63
+    backends
64
+}
65
+
66
+pub(crate) fn default_adi_backend(backends: &[AdiBackendOption]) -> usize {
67
+    backends
68
+        .iter()
69
+        .position(|backend| backend.availability.is_ready())
70
+        .unwrap_or(0)
71
+}
72
+
73
+#[allow(dead_code)]
74
+pub(crate) fn selected_adi_proxy(
75
+    kind: AdiBackendKind,
76
+    android_adi_identifier: &str,
77
+) -> Result<Box<dyn ADIProxy>, String> {
78
+    match kind {
79
+        AdiBackendKind::SystemAdid => system_adi_proxy(),
80
+        AdiBackendKind::WindowsCoreAdi => windows_coreadi_proxy(),
81
+        AdiBackendKind::AndroidCoreAdi => android_coreadi_proxy(android_adi_identifier),
82
+    }
83
+}
84
+
85
+#[allow(dead_code)]
86
+pub(crate) fn erase_adi_provisioning(
87
+    kind: AdiBackendKind,
88
+    android_adi_identifier: &str,
89
+) -> Result<(), String> {
90
+    selected_adi_proxy(kind, android_adi_identifier)?
91
+        .erase_provisioning(GRANDSLAM_DSID)
92
+        .map_err(|error| format!("Failed to erase ADI provisioning: {error}"))
93
+}
94
+
95
+pub(crate) fn provision_adi(
96
+    kind: AdiBackendKind,
97
+    machine_identity: &MachineIdentity,
98
+    android_adi_identifier: &str,
99
+) -> Result<(), String> {
100
+    tokio::runtime::Builder::new_current_thread()
101
+        .enable_all()
102
+        .build()
103
+        .map_err(|error| format!("Failed to start ADI provisioning runtime: {error}"))?
104
+        .block_on(provision_adi_async(
105
+            kind,
106
+            machine_identity,
107
+            android_adi_identifier,
108
+        ))
109
+}
110
+
111
+async fn provision_adi_async(
112
+    kind: AdiBackendKind,
113
+    machine_identity: &MachineIdentity,
114
+    android_adi_identifier: &str,
115
+) -> Result<(), String> {
116
+    let proxy = selected_adi_proxy(kind, android_adi_identifier)?;
117
+    let http_session =
118
+        grandslam::http_session(grandslam_device(machine_identity), XCODE_BUNDLE_INFORMATION)
119
+            .await
120
+            .map_err(|error| format!("Failed to create ADI provisioning session: {error}"))?;
121
+    let anisette_session = AnisetteHTTPSession::new(http_session, proxy.as_ref());
122
+
123
+    grandslam::provision(&anisette_session)
124
+        .await
125
+        .map_err(|error| format!("Failed to provision ADI: {error}"))
126
+}
127
+
128
+fn grandslam_device(machine_identity: &MachineIdentity) -> Device {
129
+    Device {
130
+        device_model: machine_identity.machine_name.to_string(),
131
+        operating_system_information: format!(
132
+            "{};{}",
133
+            machine_identity.os_name, machine_identity.os_version
134
+        ),
135
+        device_uuid: machine_identity.machine_id.to_string(),
136
+    }
137
+}
138
+
139
+fn adi_provisioning_state(
140
+    kind: AdiBackendKind,
141
+    android_adi_identifier: &str,
142
+) -> AdiProvisioningState {
143
+    match selected_adi_proxy(kind, android_adi_identifier) {
144
+        Ok(proxy) => match proxy.is_machine_provisioned(GRANDSLAM_DSID) {
145
+            Ok(true) => AdiProvisioningState::Provisioned,
146
+            Ok(false) => AdiProvisioningState::NotProvisioned,
147
+            Err(error) => AdiProvisioningState::Error(error.to_string().into()),
148
+        },
149
+        Err(error) => AdiProvisioningState::Error(error.into()),
150
+    }
151
+}
152
+
153
+#[cfg(target_os = "macos")]
154
+fn system_adi_proxy() -> Result<Box<dyn ADIProxy>, String> {
155
+    Ok(Box::new(adid_proxy::ADIdProxy::connect()))
156
+}
157
+
158
+#[cfg(not(target_os = "macos"))]
159
+fn system_adi_proxy() -> Result<Box<dyn ADIProxy>, String> {
160
+    Err("System ADI is only available on macOS.".to_string())
161
+}
162
+
163
+#[cfg(target_os = "windows")]
164
+#[self_referencing]
165
+struct WindowsCoreADIProxy {
166
+    library: dlopen2::symbor::Library,
167
+
168
+    #[borrows(library)]
169
+    #[covariant]
170
+    proxy: library_coreadi::LibraryCoreADIProxy<'this>,
171
+}
172
+
173
+#[cfg(target_os = "windows")]
174
+impl WindowsCoreADIProxy {
175
+    fn open(path: PathBuf) -> Result<Self, String> {
176
+        let library = dlopen2::symbor::Library::open(path.as_os_str())
177
+            .map_err(|error| format!("Failed to load CoreADI.dll: {error}"))?;
178
+        let proxy = WindowsCoreADIProxyTryBuilder {
179
+            library,
180
+            proxy_builder: |library| {
181
+                library_coreadi::LibraryCoreADIProxy::new(library)
182
+                    .map_err(|error| format!("CoreADI entry point could not be loaded: {error}"))
183
+            },
184
+        }
185
+        .try_build()?;
186
+
187
+        proxy
188
+            .with_proxy(|proxy| {
189
+                CoreADIADIProxy::initialize(proxy)
190
+                    .map_err(|error| format!("CoreADI initialization failed: {error}"))
191
+            })
192
+            .map(|()| proxy)
193
+    }
194
+}
195
+
196
+#[cfg(target_os = "windows")]
197
+impl CoreADIProxy for WindowsCoreADIProxy {
198
+    unsafe fn dispatch(&self, function_code: u32, parameters: *const CoreADIParameters) -> i32 {
199
+        self.with_proxy(|proxy| unsafe { proxy.dispatch(function_code, parameters) })
200
+    }
201
+}
202
+
203
+#[cfg(target_os = "windows")]
204
+fn windows_coreadi_proxy() -> Result<Box<dyn ADIProxy>, String> {
205
+    let library_path = find_windows_coreadi_library().ok_or_else(|| {
206
+        "CoreADI.dll was not found in the usual iTunes or iCloud folders.".to_string()
207
+    })?;
208
+    Ok(Box::new(WindowsCoreADIProxy::open(library_path)?))
209
+}
210
+
211
+#[cfg(not(target_os = "windows"))]
212
+fn windows_coreadi_proxy() -> Result<Box<dyn ADIProxy>, String> {
213
+    Err("iTunes or iCloud CoreADI is only available on Windows.".to_string())
214
+}
215
+
216
+fn android_coreadi_proxy(android_adi_identifier: &str) -> Result<Box<dyn ADIProxy>, String> {
217
+    let library_path = android_coreadi_library_path()
218
+        .ok_or_else(|| "The application data folder is not available.".to_string())?;
219
+    let library_data = fs::read(&library_path).map_err(|error| {
220
+        format!(
221
+            "Failed to read Apple Music CoreADI at {}: {error}",
222
+            library_path.display()
223
+        )
224
+    })?;
225
+
226
+    let proxy = android_coreadi::AndroidCoreADIProxy::load_library(library_data)
227
+        .map_err(|error| format!("Failed to load Apple Music CoreADI: {error}"))?;
228
+    CoreADIADIProxy::initialize(&proxy)
229
+        .map_err(|error| format!("Apple Music CoreADI initialization failed: {error}"))?;
230
+
231
+    let provisioning_path = android_coreadi_provisioning_path()
232
+        .ok_or_else(|| "The application data folder is not available.".to_string())?;
233
+    fs::create_dir_all(&provisioning_path).map_err(|error| {
234
+        format!(
235
+            "Failed to create CoreADI provisioning folder at {}: {error}",
236
+            provisioning_path.display()
237
+        )
238
+    })?;
239
+    let provisioning_path = c_string_from_path(&provisioning_path)?;
240
+    CoreADIADIProxy::set_provisioning_path(&proxy, &provisioning_path)
241
+        .map_err(|error| format!("Failed to configure CoreADI provisioning path: {error}"))?;
242
+
243
+    if android_adi_identifier.len() != 16 {
244
+        return Err(format!(
245
+            "Apple Music CoreADI ADI identifier must be 16 characters long, got {}.",
246
+            android_adi_identifier.len()
247
+        ));
248
+    }
249
+    CoreADIADIProxy::set_android_id(&proxy, android_adi_identifier)
250
+        .map_err(|error| format!("Failed to configure CoreADI ADI identifier: {error}"))?;
251
+
252
+    Ok(Box::new(proxy))
253
+}
254
+
255
+fn system_adi_backend() -> AdiBackendOption {
256
+    #[cfg(target_os = "macos")]
257
+    let availability = AdiBackendAvailability::Ready;
258
+
259
+    #[cfg(not(target_os = "macos"))]
260
+    let availability = AdiBackendAvailability::Unavailable;
261
+
262
+    AdiBackendOption {
263
+        kind: AdiBackendKind::SystemAdid,
264
+        name: "System ADI".into(),
265
+        detail: "Use the platform ADI provider.".into(),
266
+        availability,
267
+        details: vec![AdiBackendDetail {
268
+            label: "Identity storage".into(),
269
+            value: "System managed".into(),
270
+        }],
271
+        provisioning_state: AdiProvisioningState::Unknown,
272
+        editable_identity: false,
273
+        repair_action: None,
274
+    }
275
+}
276
+
277
+fn windows_coreadi_backend() -> AdiBackendOption {
278
+    let library_path = find_windows_coreadi_library();
279
+    let availability = if cfg!(target_os = "windows") {
280
+        if library_path.is_some() {
281
+            AdiBackendAvailability::Ready
282
+        } else {
283
+            AdiBackendAvailability::NeedsSetup
284
+        }
285
+    } else {
286
+        AdiBackendAvailability::Unavailable
287
+    };
288
+
289
+    AdiBackendOption {
290
+        kind: AdiBackendKind::WindowsCoreAdi,
291
+        name: "iTunes / iCloud CoreADI".into(),
292
+        detail: "Load Apple's Windows ADI library.".into(),
293
+        availability,
294
+        details: vec![
295
+            AdiBackendDetail {
296
+                label: "Library path".into(),
297
+                value: library_path
298
+                    .as_ref()
299
+                    .map(|path| path.display().to_string())
300
+                    .unwrap_or_else(|| "Not found".to_string())
301
+                    .into(),
302
+            },
303
+            AdiBackendDetail {
304
+                label: "Identity storage".into(),
305
+                value: "Apple managed".into(),
306
+            },
307
+        ],
308
+        provisioning_state: AdiProvisioningState::Unknown,
309
+        editable_identity: false,
310
+        repair_action: (availability == AdiBackendAvailability::NeedsSetup)
311
+            .then_some(AdiRepairAction::LocateLibrary),
312
+    }
313
+}
314
+
315
+fn android_coreadi_backend(android_adi_identifier: &str) -> AdiBackendOption {
316
+    let Some(abi) = android_coreadi_abi() else {
317
+        return AdiBackendOption {
318
+            kind: AdiBackendKind::AndroidCoreAdi,
319
+            name: "Apple Music CoreADI".into(),
320
+            detail: "Use Android's portable CoreADI library.".into(),
321
+            availability: AdiBackendAvailability::Unavailable,
322
+            details: vec![AdiBackendDetail {
323
+                label: "Architecture".into(),
324
+                value: "Unsupported".into(),
325
+            }],
326
+            provisioning_state: AdiProvisioningState::NotAvailable,
327
+            editable_identity: false,
328
+            repair_action: None,
329
+        };
330
+    };
331
+
332
+    let library_path = android_coreadi_library_path();
333
+    let availability = if library_path.as_ref().is_some_and(|path| path.exists()) {
334
+        AdiBackendAvailability::Ready
335
+    } else {
336
+        AdiBackendAvailability::NeedsSetup
337
+    };
338
+    let library_path_label = library_path
339
+        .as_ref()
340
+        .map(|path| path.display().to_string())
341
+        .unwrap_or_else(|| "Not available".to_string());
342
+    let provisioning_path_label = android_coreadi_provisioning_path()
343
+        .map(|path| path.display().to_string())
344
+        .unwrap_or_else(|| "Not available".to_string());
345
+
346
+    AdiBackendOption {
347
+        kind: AdiBackendKind::AndroidCoreAdi,
348
+        name: "Apple Music CoreADI".into(),
349
+        detail: "Use Android's portable CoreADI library.".into(),
350
+        availability,
351
+        details: vec![
352
+            AdiBackendDetail {
353
+                label: "Library path".into(),
354
+                value: library_path_label.into(),
355
+            },
356
+            AdiBackendDetail {
357
+                label: "APK ABI".into(),
358
+                value: abi.into(),
359
+            },
360
+            AdiBackendDetail {
361
+                label: "ADI identifier".into(),
362
+                value: android_adi_identifier.to_string().into(),
363
+            },
364
+            AdiBackendDetail {
365
+                label: "Provisioning path".into(),
366
+                value: provisioning_path_label.into(),
367
+            },
368
+        ],
369
+        provisioning_state: if availability.is_ready() {
370
+            AdiProvisioningState::Unknown
371
+        } else {
372
+            AdiProvisioningState::NotAvailable
373
+        },
374
+        editable_identity: true,
375
+        repair_action: (availability == AdiBackendAvailability::NeedsSetup)
376
+            .then_some(AdiRepairAction::InstallCoreAdi),
377
+    }
378
+}
379
+
380
+pub(crate) fn android_coreadi_library_path() -> Option<PathBuf> {
381
+    app_data_dir()
382
+        .zip(android_coreadi_abi())
383
+        .map(|(path, abi)| path.join(abi).join(ANDROID_COREADI_LIBRARY_FILE))
384
+}
385
+
386
+fn android_coreadi_apk_path() -> Option<PathBuf> {
387
+    app_data_dir().map(|path| path.join(ANDROID_COREADI_APK_FILE))
388
+}
389
+
390
+fn android_coreadi_provisioning_path() -> Option<PathBuf> {
391
+    app_data_dir().map(|path| path.join("coreadi"))
392
+}
393
+
394
+pub(crate) fn download_android_coreadi_apk(
395
+    mut progress: impl FnMut(AndroidCoreAdiInstallProgress),
396
+) -> Result<PathBuf, String> {
397
+    let _ = android_coreadi_abi().ok_or_else(|| {
398
+        format!(
399
+            "Apple Music CoreADI is not available for this CPU architecture ({})",
400
+            std::env::consts::ARCH
401
+        )
402
+    })?;
403
+    let destination = android_coreadi_apk_path()
404
+        .ok_or_else(|| "The application data folder is not available.".to_string())?;
405
+    if let Some(parent) = destination.parent() {
406
+        fs::create_dir_all(parent).map_err(|error| {
407
+            format!(
408
+                "Failed to create Apple Music APK folder at {}: {error}",
409
+                parent.display()
410
+            )
411
+        })?;
412
+    }
413
+
414
+    let mut response = reqwest::blocking::Client::new()
415
+        .get(ANDROID_COREADI_APK_URL)
416
+        .send()
417
+        .and_then(|response| response.error_for_status())
418
+        .map_err(|error| format!("Failed to download Apple Music APK: {error}"))?;
419
+    let total_bytes = response.content_length();
420
+    let mut file = File::create(&destination).map_err(|error| {
421
+        format!(
422
+            "Failed to create Apple Music APK at {}: {error}",
423
+            destination.display()
424
+        )
425
+    })?;
426
+    let mut downloaded_bytes = 0;
427
+    let mut buffer = [0; 64 * 1024];
428
+    progress(AndroidCoreAdiInstallProgress {
429
+        downloaded_bytes,
430
+        total_bytes,
431
+    });
432
+
433
+    loop {
434
+        let read = response
435
+            .read(&mut buffer)
436
+            .map_err(|error| format!("Failed to download Apple Music APK: {error}"))?;
437
+        if read == 0 {
438
+            break;
439
+        }
440
+
441
+        file.write_all(&buffer[..read])
442
+            .map_err(|error| format!("Failed to save Apple Music APK: {error}"))?;
443
+        downloaded_bytes += read as u64;
444
+        progress(AndroidCoreAdiInstallProgress {
445
+            downloaded_bytes,
446
+            total_bytes,
447
+        });
448
+    }
449
+
450
+    file.flush().map_err(|error| {
451
+        format!(
452
+            "Failed to flush Apple Music APK at {}: {error}",
453
+            destination.display()
454
+        )
455
+    })?;
456
+
457
+    if downloaded_bytes == 0 {
458
+        return Err("Downloaded Apple Music APK is empty.".to_string());
459
+    }
460
+
461
+    Ok(destination)
462
+}
463
+
464
+pub(crate) fn download_and_install_android_coreadi(
465
+    mut progress: impl FnMut(AndroidCoreAdiInstallEvent),
466
+) -> Result<PathBuf, String> {
467
+    let apk_path = download_android_coreadi_apk(|download| {
468
+        progress(AndroidCoreAdiInstallEvent::Downloading(download));
469
+    })?;
470
+    progress(AndroidCoreAdiInstallEvent::Installing);
471
+    install_android_coreadi_from_apk(&apk_path)
472
+}
473
+
474
+#[allow(dead_code)]
475
+pub(crate) fn install_android_coreadi_from_apk(apk_path: &Path) -> Result<PathBuf, String> {
476
+    tokio::runtime::Builder::new_current_thread()
477
+        .enable_all()
478
+        .build()
479
+        .map_err(|error| format!("Failed to start CoreADI APK reader runtime: {error}"))?
480
+        .block_on(install_android_coreadi_from_apk_async(apk_path))
481
+}
482
+
483
+async fn install_android_coreadi_from_apk_async(apk_path: &Path) -> Result<PathBuf, String> {
484
+    let entry_name = android_coreadi_apk_entry().ok_or_else(|| {
485
+        format!(
486
+            "Apple Music CoreADI is not available for this CPU architecture ({})",
487
+            std::env::consts::ARCH
488
+        )
489
+    })?;
490
+    let reader = ZipFileReader::new(apk_path).await.map_err(|error| {
491
+        format!(
492
+            "Failed to read APK archive at {}: {error}",
493
+            apk_path.display()
494
+        )
495
+    })?;
496
+    let entry_index = reader
497
+        .file()
498
+        .entries()
499
+        .iter()
500
+        .position(|entry| {
501
+            entry
502
+                .filename()
503
+                .as_str()
504
+                .is_ok_and(|filename| filename == entry_name)
505
+        })
506
+        .ok_or_else(|| format!("APK does not contain {entry_name}."))?;
507
+
508
+    let mut entry_reader = reader
509
+        .reader_without_entry(entry_index)
510
+        .await
511
+        .map_err(|error| format!("Failed to read CoreADI from APK: {error}"))?;
512
+
513
+    let destination = android_coreadi_library_path()
514
+        .ok_or_else(|| "The application data folder is not available.".to_string())?;
515
+    if let Some(parent) = destination.parent() {
516
+        fs::create_dir_all(parent).map_err(|error| {
517
+            format!(
518
+                "Failed to create CoreADI folder at {}: {error}",
519
+                parent.display()
520
+            )
521
+        })?;
522
+    }
523
+    let mut destination_file = File::create(&destination).map_err(|error| {
524
+        format!(
525
+            "Failed to create CoreADI destination at {}: {error}",
526
+            destination.display()
527
+        )
528
+    })?;
529
+    let mut buffer = [0u8; 64 * 1024];
530
+    loop {
531
+        let read = entry_reader
532
+            .read(&mut buffer)
533
+            .await
534
+            .map_err(|error| format!("Failed to extract CoreADI from APK: {error}"))?;
535
+        if read == 0 {
536
+            break;
537
+        }
538
+        destination_file
539
+            .write_all(&buffer[..read])
540
+            .map_err(|error| {
541
+                format!(
542
+                    "Failed to store CoreADI at {}: {error}",
543
+                    destination.display()
544
+                )
545
+            })?;
546
+    }
547
+    destination_file.flush().map_err(|error| {
548
+        format!(
549
+            "Failed to flush CoreADI at {}: {error}",
550
+            destination.display()
551
+        )
552
+    })?;
553
+
554
+    Ok(destination)
555
+}
556
+
557
+#[cfg(target_arch = "aarch64")]
558
+fn android_coreadi_abi() -> Option<&'static str> {
559
+    Some("arm64-v8a")
560
+}
561
+
562
+#[cfg(target_arch = "arm")]
563
+fn android_coreadi_abi() -> Option<&'static str> {
564
+    Some("armeabi-v7a")
565
+}
566
+
567
+#[cfg(target_arch = "x86")]
568
+fn android_coreadi_abi() -> Option<&'static str> {
569
+    Some("x86")
570
+}
571
+
572
+#[cfg(target_arch = "x86_64")]
573
+fn android_coreadi_abi() -> Option<&'static str> {
574
+    Some("x86_64")
575
+}
576
+
577
+#[cfg(not(any(
578
+    target_arch = "aarch64",
579
+    target_arch = "arm",
580
+    target_arch = "x86",
581
+    target_arch = "x86_64"
582
+)))]
583
+fn android_coreadi_abi() -> Option<&'static str> {
584
+    None
585
+}
586
+
587
+fn android_coreadi_apk_entry() -> Option<String> {
588
+    android_coreadi_abi().map(|abi| format!("lib/{abi}/{ANDROID_COREADI_LIBRARY_FILE}"))
589
+}
590
+
591
+fn c_string_from_path(path: &Path) -> Result<CString, String> {
592
+    CString::new(path.to_string_lossy().into_owned())
593
+        .map_err(|_| format!("Path contains a null byte: {}", path.display()))
594
+}
595
+
596
+fn find_windows_coreadi_library() -> Option<PathBuf> {
597
+    if !cfg!(target_os = "windows") {
598
+        return None;
599
+    }
600
+
601
+    windows_coreadi_candidates()
602
+        .into_iter()
603
+        .find(|path| path.exists())
604
+}
605
+
606
+fn windows_coreadi_candidates() -> Vec<PathBuf> {
607
+    let mut candidates = Vec::new();
608
+    for env_var in ["ProgramFiles", "ProgramFiles(x86)"] {
609
+        if let Some(root) = std::env::var_os(env_var) {
610
+            let root = PathBuf::from(root);
611
+            candidates.push(
612
+                root.join("Common Files")
613
+                    .join("Apple")
614
+                    .join("Apple Application Support")
615
+                    .join("CoreADI.dll"),
616
+            );
617
+            candidates.push(
618
+                root.join("Apple")
619
+                    .join("Apple Application Support")
620
+                    .join("CoreADI.dll"),
621
+            );
622
+        }
623
+    }
624
+    if let Some(root) = std::env::var_os("CommonProgramFiles") {
625
+        candidates.push(
626
+            PathBuf::from(root)
627
+                .join("Apple")
628
+                .join("Apple Application Support")
629
+                .join("CoreADI.dll"),
630
+        );
631
+    }
632
+    candidates
633
+}
... ...
@@ -0,0 +1,58 @@
1
+use crate::adi_backend;
2
+use crate::models::{AdiBackendKind, AdiBackendOption, MachineIdentity};
3
+use std::path::{Path, PathBuf};
4
+
5
+#[derive(Clone, Debug)]
6
+pub(crate) struct CoreAdiInstallProgress {
7
+    pub(crate) downloaded_bytes: u64,
8
+    pub(crate) total_bytes: Option<u64>,
9
+}
10
+
11
+#[derive(Clone, Debug)]
12
+pub(crate) enum CoreAdiInstallEvent {
13
+    Downloading(CoreAdiInstallProgress),
14
+    Installing,
15
+}
16
+
17
+pub(crate) fn available_backends(android_adi_identifier: &str) -> Vec<AdiBackendOption> {
18
+    adi_backend::available_adi_backends(android_adi_identifier)
19
+}
20
+
21
+pub(crate) fn default_backend(backends: &[AdiBackendOption]) -> usize {
22
+    adi_backend::default_adi_backend(backends)
23
+}
24
+
25
+pub(crate) fn download_and_install_coreadi(
26
+    mut progress: impl FnMut(CoreAdiInstallEvent),
27
+) -> Result<PathBuf, String> {
28
+    adi_backend::download_and_install_android_coreadi(move |event| {
29
+        progress(match event {
30
+            adi_backend::AndroidCoreAdiInstallEvent::Downloading(progress) => {
31
+                CoreAdiInstallEvent::Downloading(CoreAdiInstallProgress {
32
+                    downloaded_bytes: progress.downloaded_bytes,
33
+                    total_bytes: progress.total_bytes,
34
+                })
35
+            }
36
+            adi_backend::AndroidCoreAdiInstallEvent::Installing => CoreAdiInstallEvent::Installing,
37
+        });
38
+    })
39
+}
40
+
41
+pub(crate) fn install_coreadi_from_apk(apk_path: &Path) -> Result<PathBuf, String> {
42
+    adi_backend::install_android_coreadi_from_apk(apk_path)
43
+}
44
+
45
+pub(crate) fn erase_provisioning(
46
+    kind: AdiBackendKind,
47
+    android_adi_identifier: &str,
48
+) -> Result<(), String> {
49
+    adi_backend::erase_adi_provisioning(kind, android_adi_identifier)
50
+}
51
+
52
+pub(crate) fn provision(
53
+    kind: AdiBackendKind,
54
+    machine_identity: &MachineIdentity,
55
+    android_adi_identifier: &str,
56
+) -> Result<(), String> {
57
+    adi_backend::provision_adi(kind, machine_identity, android_adi_identifier)
58
+}
... ...
@@ -0,0 +1,137 @@
1
+use crate::ipa::read_ipa;
2
+use crate::models::{AppOption, PatchOption};
3
+use crate::preferences::{apply_app_overrides, AppPreferences};
4
+use std::path::{Path, PathBuf};
5
+
6
+#[derive(Debug, Default)]
7
+pub(crate) struct AppSelection {
8
+    apps: Vec<AppOption>,
9
+    selected: usize,
10
+    loading_path: Option<String>,
11
+    error_path: Option<String>,
12
+    load_error: Option<String>,
13
+}
14
+
15
+impl AppSelection {
16
+    pub(crate) fn from_preferences(preferences: &AppPreferences) -> Self {
17
+        let mut selection = Self::default();
18
+        let Some(path) = preferences.path.as_deref() else {
19
+            return selection;
20
+        };
21
+
22
+        if !is_ipa_path(Path::new(path)) {
23
+            selection.fail(
24
+                path.to_string(),
25
+                format!("Selected app path is not an IPA archive: {path}"),
26
+            );
27
+            return selection;
28
+        }
29
+
30
+        match load_ipa(PathBuf::from(path), Vec::new()) {
31
+            Ok(mut app) => {
32
+                apply_app_overrides(&mut app, &preferences.overrides);
33
+                selection.push_loaded(app);
34
+            }
35
+            Err(error) => {
36
+                eprintln!("{error}");
37
+                selection.fail(path.to_string(), error);
38
+            }
39
+        }
40
+
41
+        selection
42
+    }
43
+
44
+    pub(crate) fn selected(&self) -> Option<&AppOption> {
45
+        self.apps.get(self.selected)
46
+    }
47
+
48
+    pub(crate) fn selected_index(&self) -> usize {
49
+        self.selected
50
+    }
51
+
52
+    pub(crate) fn selected_path_for_preferences(&self) -> Option<String> {
53
+        self.selected()
54
+            .map(|app| app.path.to_string())
55
+            .or_else(|| self.error_path.clone())
56
+            .or_else(|| self.loading_path.clone())
57
+    }
58
+
59
+    pub(crate) fn is_loading(&self) -> bool {
60
+        self.loading_path.is_some()
61
+    }
62
+
63
+    pub(crate) fn loading_path(&self) -> Option<&str> {
64
+        self.loading_path.as_deref()
65
+    }
66
+
67
+    pub(crate) fn error_path(&self) -> Option<&str> {
68
+        self.error_path.as_deref()
69
+    }
70
+
71
+    pub(crate) fn load_error(&self) -> Option<&str> {
72
+        self.load_error.as_deref()
73
+    }
74
+
75
+    pub(crate) fn replace(&mut self, index: usize, app: AppOption) -> bool {
76
+        let Some(existing) = self.apps.get_mut(index) else {
77
+            return false;
78
+        };
79
+        *existing = app;
80
+        true
81
+    }
82
+
83
+    pub(crate) fn select_cached_path(&mut self, path: &str) -> bool {
84
+        let Some(index) = self.apps.iter().position(|app| app.path.as_ref() == path) else {
85
+            return false;
86
+        };
87
+
88
+        self.selected = index;
89
+        self.clear_transient_state();
90
+        true
91
+    }
92
+
93
+    pub(crate) fn begin_loading(&mut self, path: String) {
94
+        self.loading_path = Some(path);
95
+        self.error_path = None;
96
+        self.load_error = None;
97
+    }
98
+
99
+    pub(crate) fn finish_loading(&mut self, path: &Path, result: Result<AppOption, String>) {
100
+        match result {
101
+            Ok(app) => self.push_loaded(app),
102
+            Err(error) => {
103
+                eprintln!("{error}");
104
+                self.fail(path.to_string_lossy().to_string(), error);
105
+            }
106
+        }
107
+    }
108
+
109
+    pub(crate) fn fail(&mut self, path: String, error: String) {
110
+        self.selected = self.apps.len();
111
+        self.loading_path = None;
112
+        self.error_path = Some(path);
113
+        self.load_error = Some(error);
114
+    }
115
+
116
+    fn push_loaded(&mut self, app: AppOption) {
117
+        self.apps.push(app);
118
+        self.selected = self.apps.len() - 1;
119
+        self.clear_transient_state();
120
+    }
121
+
122
+    fn clear_transient_state(&mut self) {
123
+        self.loading_path = None;
124
+        self.error_path = None;
125
+        self.load_error = None;
126
+    }
127
+}
128
+
129
+pub(crate) fn load_ipa(path: PathBuf, patches: Vec<PatchOption>) -> Result<AppOption, String> {
130
+    read_ipa(&path, patches)
131
+}
132
+
133
+pub(crate) fn is_ipa_path(path: &Path) -> bool {
134
+    path.extension()
135
+        .and_then(|extension| extension.to_str())
136
+        .is_some_and(|extension| extension.eq_ignore_ascii_case("ipa"))
137
+}
... ...
@@ -16,8 +16,13 @@ impl AssetSource for Assets {
16 16
             "icons/plus.svg" => include_bytes!("../assets/icons/plus.svg").as_slice(),
17 17
             "icons/minus.svg" => include_bytes!("../assets/icons/minus.svg").as_slice(),
18 18
             "icons/pencil.svg" => include_bytes!("../assets/icons/pencil.svg").as_slice(),
19
+            "icons/check.svg" => include_bytes!("../assets/icons/check.svg").as_slice(),
20
+            "icons/x.svg" => include_bytes!("../assets/icons/x.svg").as_slice(),
19 21
             "icons/refresh-cw.svg" => include_bytes!("../assets/icons/refresh-cw.svg").as_slice(),
22
+            "icons/rotate-ccw.svg" => include_bytes!("../assets/icons/rotate-ccw.svg").as_slice(),
20 23
             "icons/arrow-right.svg" => include_bytes!("../assets/icons/arrow-right.svg").as_slice(),
24
+            "icons/log-out.svg" => include_bytes!("../assets/icons/log-out.svg").as_slice(),
25
+            "icons/folder-open.svg" => include_bytes!("../assets/icons/folder-open.svg").as_slice(),
21 26
             _ => return Ok(None),
22 27
         };
23 28
 
... ...
@@ -35,8 +40,13 @@ impl AssetSource for Assets {
35 40
                 "plus.svg".into(),
36 41
                 "minus.svg".into(),
37 42
                 "pencil.svg".into(),
43
+                "check.svg".into(),
44
+                "x.svg".into(),
38 45
                 "refresh-cw.svg".into(),
46
+                "rotate-ccw.svg".into(),
39 47
                 "arrow-right.svg".into(),
48
+                "log-out.svg".into(),
49
+                "folder-open.svg".into(),
40 50
             ])
41 51
         } else {
42 52
             Ok(Vec::new())
... ...
@@ -4,8 +4,8 @@ pub(crate) const SETTINGS_WINDOW_WIDTH: f32 = 520.;
4 4
 pub(crate) const TEAM_SETTINGS_WINDOW_HEIGHT: f32 = 560.;
5 5
 pub(crate) const APP_SETTINGS_WINDOW_WIDTH: f32 = 520.;
6 6
 pub(crate) const APP_SETTINGS_WINDOW_HEIGHT: f32 = 560.;
7
-pub(crate) const ADI_SETTINGS_WINDOW_WIDTH: f32 = 500.;
8
-pub(crate) const ADI_SETTINGS_WINDOW_HEIGHT: f32 = 500.;
7
+pub(crate) const ADI_SETTINGS_WINDOW_WIDTH: f32 = 560.;
8
+pub(crate) const ADI_SETTINGS_WINDOW_HEIGHT: f32 = 520.;
9 9
 pub(crate) const ADI_BACKEND_POPOVER_WIDTH: f32 = 420.;
10 10
 pub(crate) const ADI_BACKEND_POPOVER_MAX_HEIGHT: f32 = 240.;
11 11
 pub(crate) const ADI_BACKEND_COMBO_HEIGHT: f32 = 64.;
... ...
@@ -1,189 +1,13 @@
1
-use crate::models::{
2
-    AccountOption, AdiBackendOption, AppIdOption, AppOption, DeviceOption, MachineIdentity,
3
-    PatchOption, TeamOption,
4
-};
5
-use std::env;
1
+use crate::developer_accounts::load_account_options;
2
+use crate::models::{AccountOption, MachineIdentity};
6 3
 
7
-pub(crate) fn sample_accounts() -> Vec<AccountOption> {
8
-    let env_apple_id = env::var("APPLE_ID").unwrap_or_else(|_| "Add Apple ID".to_string());
9
-    let env_detail = if env_apple_id == "Add Apple ID" {
10
-        "No saved credentials".to_string()
11
-    } else {
12
-        "Loaded from APPLE_ID".to_string()
13
-    };
14
-
15
-    vec![
16
-        AccountOption {
17
-            label: "Primary Apple ID".into(),
18
-            apple_id: env_apple_id.into(),
19
-            detail: env_detail.into(),
20
-            status: "Available".into(),
21
-            teams: vec![
22
-                TeamOption {
23
-                    name: "Personal Team".into(),
24
-                    identifier: "LOCAL-PERSONAL".into(),
25
-                    role: "Free".into(),
26
-                    app_ids: sample_app_ids("LOCAL-PERSONAL"),
27
-                },
28
-                TeamOption {
29
-                    name: "Developer Program".into(),
30
-                    identifier: "TEAMID1234".into(),
31
-                    role: "Paid".into(),
32
-                    app_ids: sample_app_ids("TEAMID1234"),
33
-                },
34
-            ],
35
-        },
36
-        AccountOption {
37
-            label: "Work Apple ID".into(),
38
-            apple_id: "developer@example.com".into(),
39
-            detail: "Example account placeholder".into(),
40
-            status: "Demo".into(),
41
-            teams: vec![
42
-                TeamOption {
43
-                    name: "Example Apps LLC".into(),
44
-                    identifier: "EXAMPL3APP".into(),
45
-                    role: "Admin".into(),
46
-                    app_ids: sample_app_ids("EXAMPL3APP"),
47
-                },
48
-                TeamOption {
49
-                    name: "Client Signing Team".into(),
50
-                    identifier: "CL13NTTEAM".into(),
51
-                    role: "Member".into(),
52
-                    app_ids: sample_app_ids("CL13NTTEAM"),
53
-                },
54
-            ],
55
-        },
56
-    ]
57
-}
58
-
59
-fn sample_app_ids(team_id: &str) -> Vec<AppIdOption> {
60
-    vec![
61
-        AppIdOption {
62
-            name: "Sample App".into(),
63
-            identifier: format!("{team_id}.com.example.sample").into(),
64
-            kind: "Explicit App ID".into(),
65
-        },
66
-        AppIdOption {
67
-            name: "Wildcard Development".into(),
68
-            identifier: format!("{team_id}.*").into(),
69
-            kind: "Wildcard App ID".into(),
70
-        },
71
-    ]
4
+pub(crate) fn load_accounts() -> Vec<AccountOption> {
5
+    load_account_options().unwrap_or_else(|error| {
6
+        eprintln!("{error}");
7
+        Vec::new()
8
+    })
72 9
 }
73 10
 
74
-pub(crate) fn sample_apps() -> Vec<AppOption> {
75
-    let ipa_path = env::var("IPA_PATH").unwrap_or_else(|_| "/Applications/Sample.ipa".to_string());
76
-    let bundle_id = env::var("BUNDLE_ID").unwrap_or_else(|_| "com.example.sample".to_string());
77
-
78
-    vec![
79
-        AppOption {
80
-            name: "Sample IPA".into(),
81
-            bundle_id: bundle_id.into(),
82
-            version: "1.4.2".into(),
83
-            build: "82".into(),
84
-            path: ipa_path.into(),
85
-            patches: vec![
86
-                PatchOption {
87
-                    name: "Preserve Bundle ID".into(),
88
-                    detail: "Do not rewrite CFBundleIdentifier.".into(),
89
-                },
90
-                PatchOption {
91
-                    name: "Enable Documents".into(),
92
-                    detail: "Expose the app documents directory in Files.".into(),
93
-                },
94
-                PatchOption {
95
-                    name: "Strip Watch Extension".into(),
96
-                    detail: "Remove watchOS payloads before signing.".into(),
97
-                },
98
-            ],
99
-        },
100
-        AppOption {
101
-            name: "Debug Build".into(),
102
-            bundle_id: "com.example.debug".into(),
103
-            version: "0.9.0".into(),
104
-            build: "dev-17".into(),
105
-            path: "/tmp/debug-build.ipa".into(),
106
-            patches: vec![
107
-                PatchOption {
108
-                    name: "Inject Get-Task-Allow".into(),
109
-                    detail: "Allow debugger attachment for development.".into(),
110
-                },
111
-                PatchOption {
112
-                    name: "Remove Beta Expiry".into(),
113
-                    detail: "Patch known testflight-style expiry metadata.".into(),
114
-                },
115
-            ],
116
-        },
117
-    ]
118
-}
119
-
120
-pub(crate) fn sample_devices() -> Vec<DeviceOption> {
121
-    let udid = env::var("DEVICE_UDID").unwrap_or_else(|_| "00008110-001234DEADBEEF01".to_string());
122
-
123
-    vec![
124
-        DeviceOption {
125
-            name: "Connected iPhone".into(),
126
-            model: "iPhone16,2".into(),
127
-            os: "iOS 18.6".into(),
128
-            udid: udid.into(),
129
-            connection: "USB".into(),
130
-        },
131
-        DeviceOption {
132
-            name: "Test iPad".into(),
133
-            model: "iPad14,3".into(),
134
-            os: "iPadOS 18.5".into(),
135
-            udid: "00008103-00A1B2C3D4E5F607".into(),
136
-            connection: "Wi-Fi".into(),
137
-        },
138
-    ]
139
-}
140
-
141
-pub(crate) fn sample_adi_backends() -> Vec<AdiBackendOption> {
142
-    vec![
143
-        AdiBackendOption {
144
-            name: "Native ADI".into(),
145
-            detail: "Use the platform ADI provider for this machine.".into(),
146
-            status: "Ready".into(),
147
-            information: "Uses the local machine identity and platform services for ADI requests."
148
-                .into(),
149
-            editable_identity: false,
150
-            repair_action: None,
151
-        },
152
-        AdiBackendOption {
153
-            name: "Provisioned Files".into(),
154
-            detail: "Use a portable ADI identity stored on disk.".into(),
155
-            status: "Needs setup".into(),
156
-            information:
157
-                "Loads ADI provisioning files from the selected profile directory when available."
158
-                    .into(),
159
-            editable_identity: true,
160
-            repair_action: Some("Fix Provisioning".into()),
161
-        },
162
-        AdiBackendOption {
163
-            name: "Remote Proxy".into(),
164
-            detail: "Forward ADI requests to a configured service.".into(),
165
-            status: "Offline".into(),
166
-            information:
167
-                "Connects to an external ADI service and uses its reported machine identity.".into(),
168
-            editable_identity: false,
169
-            repair_action: Some("Reconnect".into()),
170
-        },
171
-    ]
172
-}
173
-
174
-pub(crate) fn sample_machine_identity() -> MachineIdentity {
175
-    let machine_name = env::var("HOSTNAME")
176
-        .or_else(|_| env::var("COMPUTERNAME"))
177
-        .unwrap_or_else(|_| "Local Machine".to_string());
178
-    let os_name = env::consts::OS.to_string();
179
-    let os_version = env::var("OS_VERSION").unwrap_or_else(|_| "Unknown".to_string());
180
-    let machine_id = env::var("MACHINE_ID")
181
-        .unwrap_or_else(|_| "A8B31C86-359B-4D95-8950-BA5DD8FFC46F".to_string());
182
-
183
-    MachineIdentity {
184
-        machine_name: machine_name.into(),
185
-        os_name: os_name.into(),
186
-        os_version: os_version.into(),
187
-        machine_id: machine_id.into(),
188
-    }
11
+pub(crate) fn load_machine_identity() -> MachineIdentity {
12
+    crate::system_identity::machine_identity()
189 13
 }
... ...
@@ -0,0 +1,405 @@
1
+use crate::models::{AccountOption, AppIdOption, TeamOption};
2
+use crate::paths::app_data_dir;
3
+use gpui::SharedString;
4
+use serde::{Deserialize, Serialize};
5
+use std::fs;
6
+use std::io::ErrorKind;
7
+use std::path::PathBuf;
8
+use uuid::Uuid;
9
+
10
+const ACCOUNTS_DIR: &str = "accounts";
11
+const ACCOUNT_INDEX_FILE: &str = "index.toml";
12
+const CACHE_VERSION: u32 = 1;
13
+const KEYRING_SERVICE: &str = "com.Dadoum.Super-Sideloader";
14
+
15
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
16
+#[serde(default)]
17
+pub(crate) struct DeveloperAccountIndex {
18
+    pub(crate) version: u32,
19
+    pub(crate) accounts: Vec<DeveloperAccountIndexEntry>,
20
+}
21
+
22
+#[derive(Clone, Debug, Deserialize, Serialize)]
23
+pub(crate) struct DeveloperAccountIndexEntry {
24
+    pub(crate) id: String,
25
+    pub(crate) email: String,
26
+    pub(crate) cache_file: String,
27
+    #[serde(default, skip_serializing_if = "Option::is_none")]
28
+    pub(crate) profile_name: Option<String>,
29
+    #[serde(default, skip_serializing_if = "Option::is_none")]
30
+    pub(crate) token_expires_at: Option<String>,
31
+    #[serde(default, skip_serializing_if = "Option::is_none")]
32
+    pub(crate) last_refreshed_at: Option<String>,
33
+}
34
+
35
+#[derive(Clone, Debug, Deserialize, Serialize)]
36
+#[serde(default)]
37
+pub(crate) struct DeveloperAccountCache {
38
+    pub(crate) version: u32,
39
+    pub(crate) id: String,
40
+    pub(crate) email: String,
41
+    pub(crate) profile_name: Option<String>,
42
+    pub(crate) token_expires_at: Option<String>,
43
+    pub(crate) last_refreshed_at: Option<String>,
44
+    pub(crate) teams: Vec<CachedDeveloperTeam>,
45
+}
46
+
47
+#[derive(Clone, Debug, Deserialize, Serialize)]
48
+pub(crate) struct CachedDeveloperTeam {
49
+    pub(crate) id: String,
50
+    pub(crate) name: String,
51
+    pub(crate) role: String,
52
+    #[serde(default)]
53
+    pub(crate) app_ids: Vec<CachedAppId>,
54
+}
55
+
56
+#[derive(Clone, Debug, Deserialize, Serialize)]
57
+pub(crate) struct CachedAppId {
58
+    pub(crate) id: String,
59
+    pub(crate) name: String,
60
+    pub(crate) kind: String,
61
+}
62
+
63
+impl Default for DeveloperAccountCache {
64
+    fn default() -> Self {
65
+        Self {
66
+            version: CACHE_VERSION,
67
+            id: new_account_id(),
68
+            email: String::new(),
69
+            profile_name: None,
70
+            token_expires_at: None,
71
+            last_refreshed_at: None,
72
+            teams: Vec::new(),
73
+        }
74
+    }
75
+}
76
+
77
+impl DeveloperAccountCache {
78
+    pub(crate) fn ensure_id(&mut self) -> &str {
79
+        if self.id.is_empty() {
80
+            self.id = new_account_id();
81
+        }
82
+        &self.id
83
+    }
84
+}
85
+
86
+impl From<&DeveloperAccountCache> for DeveloperAccountIndexEntry {
87
+    fn from(account: &DeveloperAccountCache) -> Self {
88
+        Self {
89
+            id: account.id.clone(),
90
+            email: account.email.clone(),
91
+            cache_file: account_cache_file_name(&account.id),
92
+            profile_name: account.profile_name.clone(),
93
+            token_expires_at: account.token_expires_at.clone(),
94
+            last_refreshed_at: account.last_refreshed_at.clone(),
95
+        }
96
+    }
97
+}
98
+
99
+impl From<DeveloperAccountCache> for AccountOption {
100
+    fn from(account: DeveloperAccountCache) -> Self {
101
+        let profile_name = account
102
+            .profile_name
103
+            .unwrap_or_else(|| account.email.clone());
104
+        let status = account
105
+            .token_expires_at
106
+            .map(|expires_at| format!("Token expires {expires_at}"))
107
+            .unwrap_or_else(|| "Cached".to_string());
108
+        let detail = account
109
+            .last_refreshed_at
110
+            .map(|refreshed_at| format!("Last refreshed {refreshed_at}"))
111
+            .unwrap_or_else(|| "Loaded from account cache".to_string());
112
+
113
+        Self {
114
+            id: account.id.into(),
115
+            label: profile_name.into(),
116
+            apple_id: account.email.into(),
117
+            detail: detail.into(),
118
+            status: status.into(),
119
+            teams: account.teams.into_iter().map(TeamOption::from).collect(),
120
+        }
121
+    }
122
+}
123
+
124
+impl From<CachedDeveloperTeam> for TeamOption {
125
+    fn from(team: CachedDeveloperTeam) -> Self {
126
+        Self {
127
+            name: team.name.into(),
128
+            identifier: team.id.into(),
129
+            role: team.role.into(),
130
+            app_ids: team.app_ids.into_iter().map(AppIdOption::from).collect(),
131
+        }
132
+    }
133
+}
134
+
135
+impl From<CachedAppId> for AppIdOption {
136
+    fn from(app_id: CachedAppId) -> Self {
137
+        Self {
138
+            name: app_id.name.into(),
139
+            identifier: app_id.id.into(),
140
+            kind: app_id.kind.into(),
141
+        }
142
+    }
143
+}
144
+
145
+pub(crate) fn load_account_options() -> Result<Vec<AccountOption>, String> {
146
+    load_cached_accounts().map(|accounts| accounts.into_iter().map(AccountOption::from).collect())
147
+}
148
+
149
+pub(crate) fn load_cached_accounts() -> Result<Vec<DeveloperAccountCache>, String> {
150
+    let index = load_account_index()?;
151
+    let mut accounts = Vec::with_capacity(index.accounts.len());
152
+
153
+    for entry in index.accounts {
154
+        match load_account_cache_file(&entry.cache_file) {
155
+            Ok(account) => accounts.push(account),
156
+            Err(error) => eprintln!("{error}"),
157
+        }
158
+    }
159
+
160
+    Ok(accounts)
161
+}
162
+
163
+#[allow(dead_code)]
164
+pub(crate) fn save_account_cache(account: &DeveloperAccountCache) -> Result<String, String> {
165
+    let mut account = account.clone();
166
+    account.ensure_id();
167
+    account.version = CACHE_VERSION;
168
+    let account_id = account.id.clone();
169
+
170
+    let cache_path = account_cache_path(&account.id)?;
171
+    if let Some(parent) = cache_path.parent() {
172
+        fs::create_dir_all(parent).map_err(|error| {
173
+            format!(
174
+                "Failed to create account cache folder at {}: {error}",
175
+                parent.display()
176
+            )
177
+        })?;
178
+    }
179
+
180
+    let contents = toml::to_string_pretty(&account)
181
+        .map_err(|error| format!("Failed to encode account cache: {error}"))?;
182
+    fs::write(&cache_path, contents).map_err(|error| {
183
+        format!(
184
+            "Failed to write account cache at {}: {error}",
185
+            cache_path.display()
186
+        )
187
+    })?;
188
+
189
+    let mut index = load_account_index()?;
190
+    let entry = DeveloperAccountIndexEntry::from(&account);
191
+    if let Some(existing) = index
192
+        .accounts
193
+        .iter_mut()
194
+        .find(|existing| existing.id == account.id)
195
+    {
196
+        *existing = entry;
197
+    } else {
198
+        index.accounts.push(entry);
199
+    }
200
+    save_account_index(&index)?;
201
+    Ok(account_id)
202
+}
203
+
204
+#[allow(dead_code)]
205
+pub(crate) fn delete_account_cache(account_id: &str) -> Result<(), String> {
206
+    let cache_path = account_cache_path(account_id)?;
207
+    match fs::remove_file(&cache_path) {
208
+        Ok(()) => {}
209
+        Err(error) if error.kind() == ErrorKind::NotFound => {}
210
+        Err(error) => {
211
+            return Err(format!(
212
+                "Failed to remove account cache at {}: {error}",
213
+                cache_path.display()
214
+            ));
215
+        }
216
+    }
217
+
218
+    let mut index = load_account_index()?;
219
+    index.accounts.retain(|account| account.id != account_id);
220
+    save_account_index(&index)
221
+}
222
+
223
+pub(crate) fn new_account_id() -> String {
224
+    Uuid::new_v4().to_string()
225
+}
226
+
227
+#[allow(dead_code)]
228
+pub(crate) fn keyring_service() -> &'static str {
229
+    KEYRING_SERVICE
230
+}
231
+
232
+#[allow(dead_code)]
233
+pub(crate) fn keyring_account_name(account_id: &str) -> String {
234
+    format!("account-session:{account_id}")
235
+}
236
+
237
+#[derive(Clone, Debug)]
238
+pub(crate) enum MockDeveloperLoginOutcome {
239
+    SignedIn(AccountOption),
240
+    RequiresTwoFactor { detail: SharedString },
241
+}
242
+
243
+pub(crate) fn mock_developer_login(
244
+    email: &str,
245
+    password: &str,
246
+) -> Result<MockDeveloperLoginOutcome, String> {
247
+    if password.is_empty() {
248
+        return Err("Enter the account password.".to_string());
249
+    }
250
+
251
+    match normalized_mock_email(email).as_deref() {
252
+        Some("a@example.com") => Ok(MockDeveloperLoginOutcome::RequiresTwoFactor {
253
+            detail: "Enter the six digit code sent to the trusted device.".into(),
254
+        }),
255
+        Some("b@example.com") => Ok(MockDeveloperLoginOutcome::SignedIn(mock_account(
256
+            "b@example.com",
257
+            "Mock Apple ID B",
258
+            "Paid",
259
+        ))),
260
+        Some(_) | None => Err("No mock Apple Account matches that email.".to_string()),
261
+    }
262
+}
263
+
264
+pub(crate) fn mock_developer_login_with_code(
265
+    email: &str,
266
+    code: &str,
267
+) -> Result<AccountOption, String> {
268
+    match normalized_mock_email(email).as_deref() {
269
+        Some("a@example.com") if code.trim() == "123456" => {
270
+            Ok(mock_account("a@example.com", "Mock Apple ID A", "Free"))
271
+        }
272
+        Some("a@example.com") => Err("The verification code is not valid.".to_string()),
273
+        _ => Err("This mock account is not waiting for verification.".to_string()),
274
+    }
275
+}
276
+
277
+fn normalized_mock_email(email: &str) -> Option<String> {
278
+    let email = email.trim().to_ascii_lowercase();
279
+    match email.as_str() {
280
+        "a" => Some("a@example.com".to_string()),
281
+        "b" => Some("b@example.com".to_string()),
282
+        "" => None,
283
+        _ => Some(email),
284
+    }
285
+}
286
+
287
+fn mock_account(email: &str, label: &str, role: &str) -> AccountOption {
288
+    let team_id = if role == "Paid" {
289
+        "MOCKPAID01"
290
+    } else {
291
+        "MOCKFREE01"
292
+    };
293
+
294
+    AccountOption {
295
+        id: new_account_id().into(),
296
+        label: label.into(),
297
+        apple_id: email.to_string().into(),
298
+        detail: "Mock login session".into(),
299
+        status: "Available".into(),
300
+        teams: vec![TeamOption {
301
+            name: "Mock Developer Team".into(),
302
+            identifier: team_id.into(),
303
+            role: role.into(),
304
+            app_ids: vec![
305
+                AppIdOption {
306
+                    name: "Selected App".into(),
307
+                    identifier: format!("{team_id}.com.example.app").into(),
308
+                    kind: "Explicit App ID".into(),
309
+                },
310
+                AppIdOption {
311
+                    name: "Wildcard Development".into(),
312
+                    identifier: format!("{team_id}.*").into(),
313
+                    kind: "Wildcard App ID".into(),
314
+                },
315
+            ],
316
+        }],
317
+    }
318
+}
319
+
320
+fn load_account_index() -> Result<DeveloperAccountIndex, String> {
321
+    let path = account_index_path()?;
322
+    let contents = match fs::read_to_string(&path) {
323
+        Ok(contents) => contents,
324
+        Err(error) if error.kind() == ErrorKind::NotFound => {
325
+            return Ok(DeveloperAccountIndex {
326
+                version: CACHE_VERSION,
327
+                accounts: Vec::new(),
328
+            });
329
+        }
330
+        Err(error) => {
331
+            return Err(format!(
332
+                "Failed to read account index at {}: {error}",
333
+                path.display()
334
+            ));
335
+        }
336
+    };
337
+
338
+    let mut index: DeveloperAccountIndex = toml::from_str(&contents).map_err(|error| {
339
+        format!(
340
+            "Failed to parse account index at {}: {error}",
341
+            path.display()
342
+        )
343
+    })?;
344
+    index.version = CACHE_VERSION;
345
+    Ok(index)
346
+}
347
+
348
+fn save_account_index(index: &DeveloperAccountIndex) -> Result<(), String> {
349
+    let path = account_index_path()?;
350
+    if let Some(parent) = path.parent() {
351
+        fs::create_dir_all(parent).map_err(|error| {
352
+            format!(
353
+                "Failed to create account index folder at {}: {error}",
354
+                parent.display()
355
+            )
356
+        })?;
357
+    }
358
+
359
+    let mut index = index.clone();
360
+    index.version = CACHE_VERSION;
361
+    let contents = toml::to_string_pretty(&index)
362
+        .map_err(|error| format!("Failed to encode account index: {error}"))?;
363
+    fs::write(&path, contents).map_err(|error| {
364
+        format!(
365
+            "Failed to write account index at {}: {error}",
366
+            path.display()
367
+        )
368
+    })
369
+}
370
+
371
+fn load_account_cache_file(cache_file: &str) -> Result<DeveloperAccountCache, String> {
372
+    let path = accounts_dir()?.join(cache_file);
373
+    let contents = fs::read_to_string(&path).map_err(|error| {
374
+        format!(
375
+            "Failed to read account cache at {}: {error}",
376
+            path.display()
377
+        )
378
+    })?;
379
+    let mut account: DeveloperAccountCache = toml::from_str(&contents).map_err(|error| {
380
+        format!(
381
+            "Failed to parse account cache at {}: {error}",
382
+            path.display()
383
+        )
384
+    })?;
385
+    account.version = CACHE_VERSION;
386
+    Ok(account)
387
+}
388
+
389
+fn account_index_path() -> Result<PathBuf, String> {
390
+    Ok(accounts_dir()?.join(ACCOUNT_INDEX_FILE))
391
+}
392
+
393
+fn account_cache_path(account_id: &str) -> Result<PathBuf, String> {
394
+    Ok(accounts_dir()?.join(account_cache_file_name(account_id)))
395
+}
396
+
397
+fn accounts_dir() -> Result<PathBuf, String> {
398
+    app_data_dir()
399
+        .map(|path| path.join(ACCOUNTS_DIR))
400
+        .ok_or_else(|| "The application data folder is not available.".to_string())
401
+}
402
+
403
+fn account_cache_file_name(account_id: &str) -> String {
404
+    format!("{}.toml", account_id)
405
+}
... ...
@@ -0,0 +1,152 @@
1
+use crate::models::DeviceOption;
2
+use futures::{channel::mpsc::UnboundedSender, StreamExt};
3
+use idevice::{
4
+    provider::UsbmuxdProvider,
5
+    services::lockdown::LockdownClient,
6
+    usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice},
7
+    IdeviceService,
8
+};
9
+use plist::Value;
10
+
11
+pub(crate) fn discover_devices() -> Result<Vec<DeviceOption>, String> {
12
+    let runtime = tokio::runtime::Builder::new_current_thread()
13
+        .enable_all()
14
+        .build()
15
+        .map_err(|error| format!("Unable to start device runtime: {error}"))?;
16
+
17
+    runtime.block_on(discover_devices_async())
18
+}
19
+
20
+pub(crate) fn watch_device_changes(
21
+    sender: UnboundedSender<Result<(), String>>,
22
+) -> Result<(), String> {
23
+    let runtime = tokio::runtime::Builder::new_current_thread()
24
+        .enable_all()
25
+        .build()
26
+        .map_err(|error| format!("Unable to start device runtime: {error}"))?;
27
+
28
+    runtime.block_on(watch_device_changes_async(sender))
29
+}
30
+
31
+async fn watch_device_changes_async(
32
+    sender: UnboundedSender<Result<(), String>>,
33
+) -> Result<(), String> {
34
+    let addr =
35
+        UsbmuxdAddr::from_env_var().map_err(|error| format!("Invalid usbmuxd address: {error}"))?;
36
+    let mut usbmuxd = addr
37
+        .connect(1)
38
+        .await
39
+        .map_err(|error| format!("Unable to connect to usbmuxd: {error}"))?;
40
+    let mut events = usbmuxd
41
+        .listen()
42
+        .await
43
+        .map_err(|error| format!("Unable to listen for device changes: {error}"))?;
44
+
45
+    while let Some(event) = events.next().await {
46
+        match event {
47
+            Ok(_) => {
48
+                if sender.unbounded_send(Ok(())).is_err() {
49
+                    return Ok(());
50
+                }
51
+            }
52
+            Err(error) => {
53
+                let message = format!("Unable to read device change: {error}");
54
+                let _ = sender.unbounded_send(Err(message.clone()));
55
+                return Err(message);
56
+            }
57
+        }
58
+    }
59
+
60
+    let message = "Device change listener ended".to_string();
61
+    let _ = sender.unbounded_send(Err(message.clone()));
62
+    Err(message)
63
+}
64
+
65
+async fn discover_devices_async() -> Result<Vec<DeviceOption>, String> {
66
+    let addr =
67
+        UsbmuxdAddr::from_env_var().map_err(|error| format!("Invalid usbmuxd address: {error}"))?;
68
+    let mut usbmuxd = addr
69
+        .connect(1)
70
+        .await
71
+        .map_err(|error| format!("Unable to connect to usbmuxd: {error}"))?;
72
+    let devices = usbmuxd
73
+        .get_devices()
74
+        .await
75
+        .map_err(|error| format!("Unable to list devices: {error}"))?;
76
+
77
+    let mut discovered = Vec::with_capacity(devices.len());
78
+    for (index, device) in devices.into_iter().enumerate() {
79
+        discovered.push(describe_device(addr.clone(), index as u32 + 2, device).await);
80
+    }
81
+
82
+    Ok(discovered)
83
+}
84
+
85
+async fn describe_device(addr: UsbmuxdAddr, tag: u32, device: UsbmuxdDevice) -> DeviceOption {
86
+    let udid = device.udid.clone();
87
+    let connection = connection_label(&device.connection_type);
88
+    let provider = UsbmuxdProvider {
89
+        addr,
90
+        tag,
91
+        udid: device.udid,
92
+        device_id: device.device_id,
93
+        label: "super-sideloader".to_string(),
94
+    };
95
+
96
+    let mut name = "Connected Device".to_string();
97
+    let mut model = "Unknown model".to_string();
98
+    let mut os = "Unknown OS".to_string();
99
+
100
+    if let Ok(mut lockdown) = LockdownClient::connect(&provider).await {
101
+        name = lockdown_string(&mut lockdown, "DeviceName")
102
+            .await
103
+            .filter(|value| !value.is_empty())
104
+            .unwrap_or(name);
105
+        model = lockdown_string(&mut lockdown, "ProductType")
106
+            .await
107
+            .filter(|value| !value.is_empty())
108
+            .unwrap_or(model);
109
+
110
+        let version = lockdown_string(&mut lockdown, "ProductVersion").await;
111
+        let class = lockdown_string(&mut lockdown, "DeviceClass").await;
112
+        if let Some(version) = version.filter(|value| !value.is_empty()) {
113
+            os = format!("{} {version}", os_name(class.as_deref(), &model));
114
+        }
115
+    }
116
+
117
+    DeviceOption {
118
+        name: name.into(),
119
+        model: model.into(),
120
+        os: os.into(),
121
+        udid: udid.into(),
122
+        connection: connection.into(),
123
+    }
124
+}
125
+
126
+async fn lockdown_string(lockdown: &mut LockdownClient, key: &str) -> Option<String> {
127
+    match lockdown.get_value(Some(key), None).await.ok()? {
128
+        Value::String(value) => Some(value),
129
+        _ => None,
130
+    }
131
+}
132
+
133
+fn connection_label(connection: &Connection) -> String {
134
+    match connection {
135
+        Connection::Usb => "USB".to_string(),
136
+        Connection::Network(_) => "Wi-Fi".to_string(),
137
+        Connection::Unknown(label) => label.clone(),
138
+    }
139
+}
140
+
141
+fn os_name(device_class: Option<&str>, model: &str) -> &'static str {
142
+    match device_class {
143
+        Some("iPad") => "iPadOS",
144
+        Some("AppleTV") => "tvOS",
145
+        Some("Watch") => "watchOS",
146
+        Some("iPhone") | Some("iPod") => "iOS",
147
+        _ if model.starts_with("iPad") => "iPadOS",
148
+        _ if model.starts_with("AppleTV") => "tvOS",
149
+        _ if model.starts_with("Watch") => "watchOS",
150
+        _ => "iOS",
151
+    }
152
+}
... ...
@@ -0,0 +1,146 @@
1
+use crate::models::DeviceOption;
2
+use crate::preferences::DevicePreferences;
3
+
4
+#[derive(Debug, Default)]
5
+pub(crate) struct DeviceSelection {
6
+    devices: Vec<DeviceOption>,
7
+    selected: usize,
8
+    selected_key: Option<(String, String)>,
9
+    refreshing: bool,
10
+    events_watching: bool,
11
+    refresh_generation: u64,
12
+    refresh_error: Option<String>,
13
+}
14
+
15
+impl DeviceSelection {
16
+    pub(crate) fn new(preferences: Option<&DevicePreferences>) -> Self {
17
+        Self {
18
+            selected_key: preferences
19
+                .map(|device| (device.udid.clone(), device.connection.clone())),
20
+            ..Self::default()
21
+        }
22
+    }
23
+
24
+    pub(crate) fn selected(&self) -> Option<&DeviceOption> {
25
+        self.devices.get(self.selected)
26
+    }
27
+
28
+    pub(crate) fn selected_index(&self) -> usize {
29
+        self.selected
30
+    }
31
+
32
+    pub(crate) fn len(&self) -> usize {
33
+        self.devices.len()
34
+    }
35
+
36
+    pub(crate) fn is_empty(&self) -> bool {
37
+        self.devices.is_empty()
38
+    }
39
+
40
+    pub(crate) fn is_refreshing(&self) -> bool {
41
+        self.refreshing
42
+    }
43
+
44
+    pub(crate) fn refresh_error(&self) -> Option<&str> {
45
+        self.refresh_error.as_deref()
46
+    }
47
+
48
+    pub(crate) fn selected_preferences(&self) -> Option<DevicePreferences> {
49
+        self.selected_key
50
+            .clone()
51
+            .or_else(|| self.selected().map(device_selection_key))
52
+            .map(|(udid, connection)| DevicePreferences { udid, connection })
53
+    }
54
+
55
+    pub(crate) fn device(&self, index: usize) -> Option<&DeviceOption> {
56
+        self.devices.get(index)
57
+    }
58
+
59
+    pub(crate) fn select(&mut self, index: usize) -> bool {
60
+        let Some(device) = self.devices.get(index) else {
61
+            return false;
62
+        };
63
+
64
+        self.selected = index;
65
+        self.selected_key = Some(device_selection_key(device));
66
+        true
67
+    }
68
+
69
+    pub(crate) fn begin_refresh(&mut self) -> Option<u64> {
70
+        if self.refreshing {
71
+            return None;
72
+        }
73
+
74
+        self.refreshing = true;
75
+        self.refresh_generation = self.refresh_generation.wrapping_add(1);
76
+        Some(self.refresh_generation)
77
+    }
78
+
79
+    pub(crate) fn finish_refresh(
80
+        &mut self,
81
+        generation: u64,
82
+        result: Result<Vec<DeviceOption>, String>,
83
+    ) {
84
+        if generation != self.refresh_generation {
85
+            return;
86
+        }
87
+
88
+        self.refreshing = false;
89
+        match result {
90
+            Ok(devices) => {
91
+                self.refresh_error = None;
92
+                self.replace_devices(devices);
93
+            }
94
+            Err(error) => {
95
+                self.refresh_error = Some(error);
96
+            }
97
+        }
98
+    }
99
+
100
+    pub(crate) fn start_events_watch(&mut self) -> bool {
101
+        if self.events_watching {
102
+            return false;
103
+        }
104
+
105
+        self.events_watching = true;
106
+        true
107
+    }
108
+
109
+    pub(crate) fn finish_events_watch(&mut self) {
110
+        self.events_watching = false;
111
+    }
112
+
113
+    pub(crate) fn note_device_event(&mut self) {
114
+        self.refresh_error = None;
115
+    }
116
+
117
+    pub(crate) fn fail_events_watch(&mut self, error: String) {
118
+        self.events_watching = false;
119
+        self.refresh_error = Some(error);
120
+    }
121
+
122
+    fn replace_devices(&mut self, devices: Vec<DeviceOption>) {
123
+        let selected_key = self
124
+            .selected_key
125
+            .clone()
126
+            .or_else(|| self.selected().map(device_selection_key));
127
+        self.devices = devices;
128
+        self.selected = selected_key
129
+            .and_then(|key| {
130
+                self.devices
131
+                    .iter()
132
+                    .position(|device| device_selection_key(device) == key)
133
+            })
134
+            .unwrap_or(0);
135
+
136
+        if self.selected >= self.devices.len() {
137
+            self.selected = 0;
138
+        }
139
+
140
+        self.selected_key = self.selected().map(device_selection_key);
141
+    }
142
+}
143
+
144
+pub(crate) fn device_selection_key(device: &DeviceOption) -> (String, String) {
145
+    (device.udid.to_string(), device.connection.to_string())
146
+}
... ...
@@ -0,0 +1,702 @@
1
+use gpui::{
2
+    actions, div, fill, point, prelude::*, px, relative, rgb, rgba, size, App, Bounds,
3
+    ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity,
4
+    EntityInputHandler, FocusHandle, Focusable, GlobalElementId, KeyBinding, LayoutId, MouseButton,
5
+    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, ShapedLine,
6
+    SharedString, Style, TextRun, UTF16Selection, UnderlineStyle, Window,
7
+};
8
+use std::ops::Range;
9
+
10
+const CONTEXT: &str = "EditLine";
11
+
12
+actions!(
13
+    edit_line,
14
+    [
15
+        Backspace,
16
+        Delete,
17
+        Left,
18
+        Right,
19
+        SelectLeft,
20
+        SelectRight,
21
+        SelectAll,
22
+        Home,
23
+        End,
24
+        Paste,
25
+        Cut,
26
+        Copy,
27
+        ShowCharacterPalette,
28
+    ]
29
+);
30
+
31
+pub(crate) fn init(cx: &mut App) {
32
+    cx.bind_keys([
33
+        KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
34
+        KeyBinding::new("delete", Delete, Some(CONTEXT)),
35
+        KeyBinding::new("left", Left, Some(CONTEXT)),
36
+        KeyBinding::new("right", Right, Some(CONTEXT)),
37
+        KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
38
+        KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
39
+        KeyBinding::new("home", Home, Some(CONTEXT)),
40
+        KeyBinding::new("end", End, Some(CONTEXT)),
41
+        #[cfg(target_os = "macos")]
42
+        KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)),
43
+        #[cfg(not(target_os = "macos"))]
44
+        KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)),
45
+        #[cfg(target_os = "macos")]
46
+        KeyBinding::new("cmd-v", Paste, Some(CONTEXT)),
47
+        #[cfg(not(target_os = "macos"))]
48
+        KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)),
49
+        #[cfg(target_os = "macos")]
50
+        KeyBinding::new("cmd-c", Copy, Some(CONTEXT)),
51
+        #[cfg(not(target_os = "macos"))]
52
+        KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)),
53
+        #[cfg(target_os = "macos")]
54
+        KeyBinding::new("cmd-x", Cut, Some(CONTEXT)),
55
+        #[cfg(not(target_os = "macos"))]
56
+        KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)),
57
+        #[cfg(target_os = "macos")]
58
+        KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
59
+    ]);
60
+}
61
+
62
+pub(crate) struct EditLine {
63
+    focus_handle: FocusHandle,
64
+    content: SharedString,
65
+    selected_range: Range<usize>,
66
+    selection_reversed: bool,
67
+    marked_range: Option<Range<usize>>,
68
+    last_layout: Option<ShapedLine>,
69
+    last_bounds: Option<Bounds<Pixels>>,
70
+    scroll_x: Pixels,
71
+    is_selecting: bool,
72
+    masked: bool,
73
+}
74
+
75
+impl EditLine {
76
+    pub(crate) fn new(value: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
77
+        let content = value.into();
78
+        let end = content.len();
79
+        Self {
80
+            focus_handle: cx.focus_handle().tab_stop(true),
81
+            content,
82
+            selected_range: end..end,
83
+            selection_reversed: false,
84
+            marked_range: None,
85
+            last_layout: None,
86
+            last_bounds: None,
87
+            scroll_x: Pixels::ZERO,
88
+            is_selecting: false,
89
+            masked: false,
90
+        }
91
+    }
92
+
93
+    pub(crate) fn new_password(value: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
94
+        let mut input = Self::new(value, cx);
95
+        input.masked = true;
96
+        input
97
+    }
98
+
99
+    pub(crate) fn value(&self) -> String {
100
+        self.content.to_string()
101
+    }
102
+
103
+    pub(crate) fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
104
+        self.focus_handle.focus(window, cx);
105
+    }
106
+
107
+    fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
108
+        if self.selected_range.is_empty() {
109
+            self.move_to(self.previous_boundary(self.cursor_offset()), cx);
110
+        } else {
111
+            self.move_to(self.selected_range.start, cx)
112
+        }
113
+    }
114
+
115
+    fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
116
+        if self.selected_range.is_empty() {
117
+            self.move_to(self.next_boundary(self.selected_range.end), cx);
118
+        } else {
119
+            self.move_to(self.selected_range.end, cx)
120
+        }
121
+    }
122
+
123
+    fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
124
+        self.select_to(self.previous_boundary(self.cursor_offset()), cx);
125
+    }
126
+
127
+    fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
128
+        self.select_to(self.next_boundary(self.cursor_offset()), cx);
129
+    }
130
+
131
+    fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
132
+        self.move_to(0, cx);
133
+        self.select_to(self.content.len(), cx)
134
+    }
135
+
136
+    fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
137
+        self.move_to(0, cx);
138
+    }
139
+
140
+    fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
141
+        self.move_to(self.content.len(), cx);
142
+    }
143
+
144
+    fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
145
+        if self.selected_range.is_empty() {
146
+            self.select_to(self.previous_boundary(self.cursor_offset()), cx)
147
+        }
148
+        self.replace_text_in_range(None, "", window, cx)
149
+    }
150
+
151
+    fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
152
+        if self.selected_range.is_empty() {
153
+            self.select_to(self.next_boundary(self.cursor_offset()), cx)
154
+        }
155
+        self.replace_text_in_range(None, "", window, cx)
156
+    }
157
+
158
+    fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
159
+        if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
160
+            self.replace_text_in_range(None, &text.replace('\n', " "), window, cx);
161
+        }
162
+    }
163
+
164
+    fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
165
+        if !self.selected_range.is_empty() {
166
+            cx.write_to_clipboard(ClipboardItem::new_string(
167
+                self.content[self.selected_range.clone()].to_string(),
168
+            ));
169
+        }
170
+    }
171
+
172
+    fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
173
+        if !self.selected_range.is_empty() {
174
+            cx.write_to_clipboard(ClipboardItem::new_string(
175
+                self.content[self.selected_range.clone()].to_string(),
176
+            ));
177
+            self.replace_text_in_range(None, "", window, cx);
178
+        }
179
+    }
180
+
181
+    fn show_character_palette(
182
+        &mut self,
183
+        _: &ShowCharacterPalette,
184
+        window: &mut Window,
185
+        _: &mut Context<Self>,
186
+    ) {
187
+        window.show_character_palette();
188
+    }
189
+
190
+    fn on_mouse_down(
191
+        &mut self,
192
+        event: &MouseDownEvent,
193
+        window: &mut Window,
194
+        cx: &mut Context<Self>,
195
+    ) {
196
+        self.focus_handle.focus(window, cx);
197
+        self.is_selecting = true;
198
+        if event.modifiers.shift {
199
+            self.select_to(self.index_for_mouse_position(event.position), cx);
200
+        } else {
201
+            self.move_to(self.index_for_mouse_position(event.position), cx);
202
+        }
203
+    }
204
+
205
+    fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
206
+        self.is_selecting = false;
207
+    }
208
+
209
+    fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
210
+        if self.is_selecting {
211
+            self.select_to(self.index_for_mouse_position(event.position), cx);
212
+        }
213
+    }
214
+
215
+    fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
216
+        self.selected_range = offset..offset;
217
+        self.selection_reversed = false;
218
+        cx.notify();
219
+    }
220
+
221
+    fn cursor_offset(&self) -> usize {
222
+        if self.selection_reversed {
223
+            self.selected_range.start
224
+        } else {
225
+            self.selected_range.end
226
+        }
227
+    }
228
+
229
+    fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
230
+        if self.content.is_empty() {
231
+            return 0;
232
+        }
233
+
234
+        let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref())
235
+        else {
236
+            return 0;
237
+        };
238
+        if position.y < bounds.top() {
239
+            return 0;
240
+        }
241
+        if position.y > bounds.bottom() {
242
+            return self.content.len();
243
+        }
244
+        line.closest_index_for_x(position.x - bounds.left() + self.scroll_x)
245
+    }
246
+
247
+    fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
248
+        if self.selection_reversed {
249
+            self.selected_range.start = offset
250
+        } else {
251
+            self.selected_range.end = offset
252
+        };
253
+        if self.selected_range.end < self.selected_range.start {
254
+            self.selection_reversed = !self.selection_reversed;
255
+            self.selected_range = self.selected_range.end..self.selected_range.start;
256
+        }
257
+        cx.notify();
258
+    }
259
+
260
+    fn offset_from_utf16(&self, offset: usize) -> usize {
261
+        let mut utf8_offset = 0;
262
+        let mut utf16_count = 0;
263
+
264
+        for ch in self.content.chars() {
265
+            if utf16_count >= offset {
266
+                break;
267
+            }
268
+            utf16_count += ch.len_utf16();
269
+            utf8_offset += ch.len_utf8();
270
+        }
271
+
272
+        utf8_offset
273
+    }
274
+
275
+    fn offset_to_utf16(&self, offset: usize) -> usize {
276
+        let mut utf16_offset = 0;
277
+        let mut utf8_count = 0;
278
+
279
+        for ch in self.content.chars() {
280
+            if utf8_count >= offset {
281
+                break;
282
+            }
283
+            utf8_count += ch.len_utf8();
284
+            utf16_offset += ch.len_utf16();
285
+        }
286
+
287
+        utf16_offset
288
+    }
289
+
290
+    fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
291
+        self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
292
+    }
293
+
294
+    fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
295
+        self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
296
+    }
297
+
298
+    fn previous_boundary(&self, offset: usize) -> usize {
299
+        let offset = snap_to_char_boundary(&self.content, offset);
300
+        self.content[..offset]
301
+            .char_indices()
302
+            .last()
303
+            .map(|(index, _)| index)
304
+            .unwrap_or(0)
305
+    }
306
+
307
+    fn next_boundary(&self, offset: usize) -> usize {
308
+        let offset = snap_to_char_boundary(&self.content, offset);
309
+        self.content[offset..]
310
+            .char_indices()
311
+            .nth(1)
312
+            .map(|(index, _)| offset + index)
313
+            .unwrap_or(self.content.len())
314
+    }
315
+}
316
+
317
+impl EntityInputHandler for EditLine {
318
+    fn text_for_range(
319
+        &mut self,
320
+        range_utf16: Range<usize>,
321
+        actual_range: &mut Option<Range<usize>>,
322
+        _: &mut Window,
323
+        _: &mut Context<Self>,
324
+    ) -> Option<String> {
325
+        let range = self.range_from_utf16(&range_utf16);
326
+        actual_range.replace(self.range_to_utf16(&range));
327
+        Some(self.content[range].to_string())
328
+    }
329
+
330
+    fn selected_text_range(
331
+        &mut self,
332
+        _: bool,
333
+        _: &mut Window,
334
+        _: &mut Context<Self>,
335
+    ) -> Option<UTF16Selection> {
336
+        Some(UTF16Selection {
337
+            range: self.range_to_utf16(&self.selected_range),
338
+            reversed: self.selection_reversed,
339
+        })
340
+    }
341
+
342
+    fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
343
+        self.marked_range
344
+            .as_ref()
345
+            .map(|range| self.range_to_utf16(range))
346
+    }
347
+
348
+    fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
349
+        self.marked_range = None;
350
+    }
351
+
352
+    fn replace_text_in_range(
353
+        &mut self,
354
+        range_utf16: Option<Range<usize>>,
355
+        new_text: &str,
356
+        _: &mut Window,
357
+        cx: &mut Context<Self>,
358
+    ) {
359
+        let range = range_utf16
360
+            .as_ref()
361
+            .map(|range_utf16| self.range_from_utf16(range_utf16))
362
+            .or(self.marked_range.clone())
363
+            .unwrap_or(self.selected_range.clone());
364
+
365
+        self.content =
366
+            (self.content[0..range.start].to_owned() + new_text + &self.content[range.end..])
367
+                .into();
368
+        self.selected_range = range.start + new_text.len()..range.start + new_text.len();
369
+        self.selection_reversed = false;
370
+        self.marked_range.take();
371
+        cx.notify();
372
+    }
373
+
374
+    fn replace_and_mark_text_in_range(
375
+        &mut self,
376
+        range_utf16: Option<Range<usize>>,
377
+        new_text: &str,
378
+        new_selected_range_utf16: Option<Range<usize>>,
379
+        _: &mut Window,
380
+        cx: &mut Context<Self>,
381
+    ) {
382
+        let range = range_utf16
383
+            .as_ref()
384
+            .map(|range_utf16| self.range_from_utf16(range_utf16))
385
+            .or(self.marked_range.clone())
386
+            .unwrap_or(self.selected_range.clone());
387
+
388
+        self.content =
389
+            (self.content[0..range.start].to_owned() + new_text + &self.content[range.end..])
390
+                .into();
391
+        self.marked_range =
392
+            (!new_text.is_empty()).then_some(range.start..range.start + new_text.len());
393
+        self.selected_range = new_selected_range_utf16
394
+            .as_ref()
395
+            .map(|range_utf16| self.range_from_utf16(range_utf16))
396
+            .map(|new_range| new_range.start + range.start..new_range.end + range.end)
397
+            .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len());
398
+        self.selection_reversed = false;
399
+        cx.notify();
400
+    }
401
+
402
+    fn bounds_for_range(
403
+        &mut self,
404
+        range_utf16: Range<usize>,
405
+        bounds: Bounds<Pixels>,
406
+        _: &mut Window,
407
+        _: &mut Context<Self>,
408
+    ) -> Option<Bounds<Pixels>> {
409
+        let last_layout = self.last_layout.as_ref()?;
410
+        let range = self.range_from_utf16(&range_utf16);
411
+        Some(Bounds::from_corners(
412
+            point(
413
+                bounds.left() + last_layout.x_for_index(range.start) - self.scroll_x,
414
+                bounds.top(),
415
+            ),
416
+            point(
417
+                bounds.left() + last_layout.x_for_index(range.end) - self.scroll_x,
418
+                bounds.bottom(),
419
+            ),
420
+        ))
421
+    }
422
+
423
+    fn character_index_for_point(
424
+        &mut self,
425
+        point: Point<Pixels>,
426
+        _: &mut Window,
427
+        _: &mut Context<Self>,
428
+    ) -> Option<usize> {
429
+        let line_point = self.last_bounds?.localize(&point)?;
430
+        let last_layout = self.last_layout.as_ref()?;
431
+        let utf8_index = last_layout.index_for_x(line_point.x + self.scroll_x)?;
432
+        Some(self.offset_to_utf16(utf8_index))
433
+    }
434
+}
435
+
436
+impl Render for EditLine {
437
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
438
+        div()
439
+            .key_context(CONTEXT)
440
+            .track_focus(&self.focus_handle(cx))
441
+            .cursor(CursorStyle::IBeam)
442
+            .on_action(cx.listener(Self::backspace))
443
+            .on_action(cx.listener(Self::delete))
444
+            .on_action(cx.listener(Self::left))
445
+            .on_action(cx.listener(Self::right))
446
+            .on_action(cx.listener(Self::select_left))
447
+            .on_action(cx.listener(Self::select_right))
448
+            .on_action(cx.listener(Self::select_all))
449
+            .on_action(cx.listener(Self::home))
450
+            .on_action(cx.listener(Self::end))
451
+            .on_action(cx.listener(Self::paste))
452
+            .on_action(cx.listener(Self::cut))
453
+            .on_action(cx.listener(Self::copy))
454
+            .on_action(cx.listener(Self::show_character_palette))
455
+            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
456
+            .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
457
+            .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
458
+            .on_mouse_move(cx.listener(Self::on_mouse_move))
459
+            .min_w_0()
460
+            .w_full()
461
+            .child(EditLineElement { input: cx.entity() })
462
+    }
463
+}
464
+
465
+impl Focusable for EditLine {
466
+    fn focus_handle(&self, _: &App) -> FocusHandle {
467
+        self.focus_handle.clone()
468
+    }
469
+}
470
+
471
+struct EditLineElement {
472
+    input: Entity<EditLine>,
473
+}
474
+
475
+struct PrepaintState {
476
+    line: Option<ShapedLine>,
477
+    cursor: Option<PaintQuad>,
478
+    selection: Option<PaintQuad>,
479
+    scroll_x: Pixels,
480
+}
481
+
482
+impl IntoElement for EditLineElement {
483
+    type Element = Self;
484
+
485
+    fn into_element(self) -> Self::Element {
486
+        self
487
+    }
488
+}
489
+
490
+impl Element for EditLineElement {
491
+    type RequestLayoutState = ();
492
+    type PrepaintState = PrepaintState;
493
+
494
+    fn id(&self) -> Option<ElementId> {
495
+        None
496
+    }
497
+
498
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
499
+        None
500
+    }
501
+
502
+    fn request_layout(
503
+        &mut self,
504
+        _: Option<&GlobalElementId>,
505
+        _: Option<&gpui::InspectorElementId>,
506
+        window: &mut Window,
507
+        cx: &mut App,
508
+    ) -> (LayoutId, Self::RequestLayoutState) {
509
+        let mut style = Style::default();
510
+        style.size.width = relative(1.).into();
511
+        style.size.height = window.line_height().into();
512
+        (window.request_layout(style, [], cx), ())
513
+    }
514
+
515
+    fn prepaint(
516
+        &mut self,
517
+        _: Option<&GlobalElementId>,
518
+        _: Option<&gpui::InspectorElementId>,
519
+        bounds: Bounds<Pixels>,
520
+        _: &mut Self::RequestLayoutState,
521
+        window: &mut Window,
522
+        cx: &mut App,
523
+    ) -> Self::PrepaintState {
524
+        let input = self.input.read(cx);
525
+        let content = if input.masked {
526
+            "*".repeat(input.content.len()).into()
527
+        } else {
528
+            input.content.clone()
529
+        };
530
+        let selected_range = input.selected_range.clone();
531
+        let cursor = input.cursor_offset();
532
+        let previous_scroll_x = input.scroll_x;
533
+        let style = window.text_style();
534
+
535
+        let run = TextRun {
536
+            len: content.len(),
537
+            font: style.font(),
538
+            color: style.color,
539
+            background_color: None,
540
+            underline: None,
541
+            strikethrough: None,
542
+        };
543
+        let runs = if let Some(marked_range) = input.marked_range.as_ref() {
544
+            vec![
545
+                TextRun {
546
+                    len: marked_range.start,
547
+                    ..run.clone()
548
+                },
549
+                TextRun {
550
+                    len: marked_range.end - marked_range.start,
551
+                    underline: Some(UnderlineStyle {
552
+                        color: Some(run.color),
553
+                        thickness: px(1.),
554
+                        wavy: false,
555
+                    }),
556
+                    ..run.clone()
557
+                },
558
+                TextRun {
559
+                    len: content.len() - marked_range.end,
560
+                    ..run
561
+                },
562
+            ]
563
+            .into_iter()
564
+            .filter(|run| run.len > 0)
565
+            .collect()
566
+        } else {
567
+            vec![run]
568
+        };
569
+
570
+        let font_size = style.font_size.to_pixels(window.rem_size());
571
+        let line = window
572
+            .text_system()
573
+            .shape_line(content, font_size, &runs, None);
574
+        let cursor_pos = line.x_for_index(cursor);
575
+        let scroll_x = adjusted_scroll_x(
576
+            previous_scroll_x,
577
+            line.width(),
578
+            bounds.size.width,
579
+            cursor_pos,
580
+        );
581
+        let (selection, cursor) = if selected_range.is_empty() {
582
+            (
583
+                None,
584
+                Some(fill(
585
+                    Bounds::new(
586
+                        point(bounds.left() + cursor_pos - scroll_x, bounds.top() + px(2.)),
587
+                        size(px(1.), bounds.bottom() - bounds.top() - px(4.)),
588
+                    ),
589
+                    rgb(0x173f45),
590
+                )),
591
+            )
592
+        } else {
593
+            (
594
+                Some(fill(
595
+                    Bounds::from_corners(
596
+                        point(
597
+                            bounds.left() + line.x_for_index(selected_range.start) - scroll_x,
598
+                            bounds.top() + px(1.),
599
+                        ),
600
+                        point(
601
+                            bounds.left() + line.x_for_index(selected_range.end) - scroll_x,
602
+                            bounds.bottom() - px(1.),
603
+                        ),
604
+                    ),
605
+                    rgba(0x33298791),
606
+                )),
607
+                None,
608
+            )
609
+        };
610
+
611
+        PrepaintState {
612
+            line: Some(line),
613
+            cursor,
614
+            selection,
615
+            scroll_x,
616
+        }
617
+    }
618
+
619
+    fn paint(
620
+        &mut self,
621
+        _: Option<&GlobalElementId>,
622
+        _: Option<&gpui::InspectorElementId>,
623
+        bounds: Bounds<Pixels>,
624
+        _: &mut Self::RequestLayoutState,
625
+        prepaint: &mut Self::PrepaintState,
626
+        window: &mut Window,
627
+        cx: &mut App,
628
+    ) {
629
+        let focus_handle = self.input.read(cx).focus_handle.clone();
630
+        window.handle_input(
631
+            &focus_handle,
632
+            ElementInputHandler::new(bounds, self.input.clone()),
633
+            cx,
634
+        );
635
+        if let Some(selection) = prepaint.selection.take() {
636
+            window.paint_quad(selection);
637
+        }
638
+        let line = prepaint.line.take().unwrap();
639
+        let scroll_x = prepaint.scroll_x;
640
+        line.paint(
641
+            point(bounds.origin.x - scroll_x, bounds.origin.y),
642
+            window.line_height(),
643
+            gpui::TextAlign::Left,
644
+            None,
645
+            window,
646
+            cx,
647
+        )
648
+        .unwrap();
649
+
650
+        if focus_handle.is_focused(window) {
651
+            if let Some(cursor) = prepaint.cursor.take() {
652
+                window.paint_quad(cursor);
653
+            }
654
+        }
655
+
656
+        self.input.update(cx, |input, _| {
657
+            input.last_layout = Some(line);
658
+            input.last_bounds = Some(bounds);
659
+            input.scroll_x = scroll_x;
660
+        });
661
+    }
662
+}
663
+
664
+fn adjusted_scroll_x(
665
+    previous: Pixels,
666
+    line_width: Pixels,
667
+    visible_width: Pixels,
668
+    cursor_x: Pixels,
669
+) -> Pixels {
670
+    let padding = px(4.);
671
+    if line_width.as_f32() <= visible_width.as_f32() {
672
+        return Pixels::ZERO;
673
+    }
674
+
675
+    let mut scroll_x = previous;
676
+    let right_edge = scroll_x + visible_width - padding;
677
+    if cursor_x.as_f32() > right_edge.as_f32() {
678
+        scroll_x = cursor_x - visible_width + padding;
679
+    }
680
+
681
+    let left_edge = scroll_x + padding;
682
+    if cursor_x.as_f32() < left_edge.as_f32() {
683
+        scroll_x = cursor_x - padding;
684
+    }
685
+
686
+    let max_scroll = line_width - visible_width + padding;
687
+    if scroll_x.as_f32() < 0. {
688
+        Pixels::ZERO
689
+    } else if scroll_x.as_f32() > max_scroll.as_f32() {
690
+        max_scroll
691
+    } else {
692
+        scroll_x
693
+    }
694
+}
695
+
696
+fn snap_to_char_boundary(value: &str, mut cursor: usize) -> usize {
697
+    cursor = cursor.min(value.len());
698
+    while !value.is_char_boundary(cursor) {
699
+        cursor -= 1;
700
+    }
701
+    cursor
702
+}
... ...
@@ -0,0 +1,323 @@
1
+use crate::models::{
2
+    AppEntitlement, AppMetadata, AppOption, EntitlementValue, PatchOption, SupportedDeviceFamily,
3
+};
4
+use crate::paths::app_data_dir;
5
+use async_zip::tokio::read::fs::ZipFileReader;
6
+use futures_lite::io::AsyncReadExt;
7
+use plist::{Dictionary, Value};
8
+use std::fs;
9
+use std::io::Cursor;
10
+use std::path::{Path, PathBuf};
11
+
12
+pub(crate) fn read_ipa(path: &Path, patches: Vec<PatchOption>) -> Result<AppOption, String> {
13
+    tokio::runtime::Builder::new_current_thread()
14
+        .enable_all()
15
+        .build()
16
+        .map_err(|error| format!("Failed to start IPA reader runtime: {error}"))?
17
+        .block_on(read_ipa_async(path, patches))
18
+}
19
+
20
+async fn read_ipa_async(path: &Path, patches: Vec<PatchOption>) -> Result<AppOption, String> {
21
+    let reader = ZipFileReader::new(path)
22
+        .await
23
+        .map_err(|error| format!("Failed to read IPA archive at {}: {error}", path.display()))?;
24
+
25
+    let info_index = reader
26
+        .file()
27
+        .entries()
28
+        .iter()
29
+        .position(|entry| entry_name(entry).is_some_and(is_app_info_plist))
30
+        .ok_or_else(|| "IPA does not contain Payload/*.app/Info.plist.".to_string())?;
31
+    let info_path = entry_name(&reader.file().entries()[info_index])
32
+        .ok_or_else(|| "IPA Info.plist entry name is invalid.".to_string())?;
33
+    let app_prefix = info_path
34
+        .strip_suffix("Info.plist")
35
+        .ok_or_else(|| "IPA Info.plist entry path is invalid.".to_string())?
36
+        .to_string();
37
+
38
+    let info_bytes = read_entry(&reader, info_index, "Info.plist").await?;
39
+    let info = plist::from_bytes::<Dictionary>(&info_bytes)
40
+        .map_err(|error| format!("Failed to parse IPA Info.plist: {error}"))?;
41
+
42
+    let bundle_id = required_plist_string(&info, "CFBundleIdentifier")?;
43
+    let executable = required_plist_string(&info, "CFBundleExecutable")?;
44
+    let version = required_plist_string(&info, "CFBundleShortVersionString")?;
45
+    let build = required_plist_string(&info, "CFBundleVersion")?;
46
+    let minimum_os = required_plist_string(&info, "MinimumOSVersion")?;
47
+    let name = plist_string(&info, "CFBundleDisplayName")
48
+        .or_else(|| plist_string(&info, "CFBundleName"))
49
+        .unwrap_or(executable);
50
+    let supported_devices = supported_devices(&info);
51
+
52
+    let icon_path = extract_icon(&reader, &app_prefix, &info, bundle_id).await;
53
+    let entitlements = extract_entitlements(&reader, &app_prefix)
54
+        .await
55
+        .unwrap_or_else(|| fallback_entitlements(bundle_id));
56
+
57
+    Ok(AppOption {
58
+        metadata: AppMetadata::sample(
59
+            name,
60
+            bundle_id,
61
+            version,
62
+            build,
63
+            executable,
64
+            minimum_os,
65
+            supported_devices,
66
+        ),
67
+        path: path.to_string_lossy().to_string().into(),
68
+        icon_path: icon_path.map(|path| path.to_string_lossy().to_string().into()),
69
+        icon_override_path: None,
70
+        entitlements,
71
+        entitlement_overrides: None,
72
+        patches,
73
+    })
74
+}
75
+
76
+fn is_app_info_plist(path: &str) -> bool {
77
+    path.starts_with("Payload/")
78
+        && path.ends_with(".app/Info.plist")
79
+        && path["Payload/".len()..].contains(".app/")
80
+}
81
+
82
+fn entry_name(entry: &async_zip::ZipEntry) -> Option<&str> {
83
+    entry.filename().as_str().ok()
84
+}
85
+
86
+fn required_plist_string<'a>(info: &'a Dictionary, key: &str) -> Result<&'a str, String> {
87
+    plist_string(info, key).ok_or_else(|| format!("IPA Info.plist is missing string key {key}."))
88
+}
89
+
90
+async fn read_entry(reader: &ZipFileReader, index: usize, label: &str) -> Result<Vec<u8>, String> {
91
+    let mut entry_reader = reader
92
+        .reader_without_entry(index)
93
+        .await
94
+        .map_err(|error| format!("Failed to read {label} from IPA: {error}"))?;
95
+    let mut data = Vec::new();
96
+    entry_reader
97
+        .read_to_end(&mut data)
98
+        .await
99
+        .map_err(|error| format!("Failed to extract {label} from IPA: {error}"))?;
100
+    Ok(data)
101
+}
102
+
103
+fn plist_string<'a>(plist: &'a Dictionary, key: &str) -> Option<&'a str> {
104
+    plist.get(key).and_then(Value::as_string)
105
+}
106
+
107
+fn supported_devices(info: &Dictionary) -> Vec<SupportedDeviceFamily> {
108
+    let Some(Value::Array(families)) = info.get("UIDeviceFamily") else {
109
+        return Vec::new();
110
+    };
111
+
112
+    let mut devices = families
113
+        .iter()
114
+        .filter_map(|value| value.as_unsigned_integer())
115
+        .filter_map(SupportedDeviceFamily::from_device_family_id)
116
+        .collect::<Vec<_>>();
117
+    devices.sort();
118
+    devices.dedup();
119
+    devices
120
+}
121
+
122
+async fn extract_icon(
123
+    reader: &ZipFileReader,
124
+    app_prefix: &str,
125
+    info: &Dictionary,
126
+    bundle_id: &str,
127
+) -> Option<PathBuf> {
128
+    let mut candidates = icon_candidates(info);
129
+    candidates.reverse();
130
+
131
+    for candidate in candidates {
132
+        if let Some((index, name)) = find_icon_entry(reader, app_prefix, &candidate) {
133
+            if let Ok(bytes) = read_entry(reader, index, name).await {
134
+                if let Some(path) = cache_icon(bundle_id, &bytes) {
135
+                    return Some(path);
136
+                }
137
+            }
138
+        }
139
+    }
140
+
141
+    let fallback = reader
142
+        .file()
143
+        .entries()
144
+        .iter()
145
+        .enumerate()
146
+        .filter_map(|(index, entry)| {
147
+            let name = entry_name(entry)?;
148
+            let lower = name.to_ascii_lowercase();
149
+            (name.starts_with(app_prefix)
150
+                && lower.ends_with(".png")
151
+                && (lower.contains("appicon") || lower.contains("icon")))
152
+            .then_some((index, name, entry.compressed_size()))
153
+        })
154
+        .max_by_key(|(_, _, size)| *size)
155
+        .map(|(index, name, _)| (index, name));
156
+
157
+    let Some((index, name)) = fallback else {
158
+        return None;
159
+    };
160
+    let bytes = read_entry(reader, index, name).await.ok()?;
161
+    cache_icon(bundle_id, &bytes)
162
+}
163
+
164
+fn icon_candidates(info: &Dictionary) -> Vec<String> {
165
+    let mut candidates = Vec::new();
166
+    append_icon_candidates(info.get("CFBundleIconFiles"), &mut candidates);
167
+    append_icon_candidates_from_icons(info.get("CFBundleIcons"), &mut candidates);
168
+    append_icon_candidates_from_icons(info.get("CFBundleIcons~ipad"), &mut candidates);
169
+    candidates
170
+}
171
+
172
+fn append_icon_candidates(value: Option<&Value>, candidates: &mut Vec<String>) {
173
+    match value {
174
+        Some(Value::Array(values)) => {
175
+            candidates.extend(
176
+                values
177
+                    .iter()
178
+                    .filter_map(Value::as_string)
179
+                    .map(str::to_string),
180
+            );
181
+        }
182
+        Some(Value::String(value)) => candidates.push(value.clone()),
183
+        _ => {}
184
+    }
185
+}
186
+
187
+fn append_icon_candidates_from_icons(value: Option<&Value>, candidates: &mut Vec<String>) {
188
+    let Some(Value::Dictionary(icons)) = value else {
189
+        return;
190
+    };
191
+    let Some(Value::Dictionary(primary_icon)) = icons.get("CFBundlePrimaryIcon") else {
192
+        return;
193
+    };
194
+    append_icon_candidates(primary_icon.get("CFBundleIconFiles"), candidates);
195
+}
196
+
197
+fn find_icon_entry<'a>(
198
+    reader: &'a ZipFileReader,
199
+    app_prefix: &str,
200
+    candidate: &str,
201
+) -> Option<(usize, &'a str)> {
202
+    let candidate = candidate.trim_start_matches('/');
203
+    let forms = if candidate.ends_with(".png") {
204
+        vec![candidate.to_string()]
205
+    } else {
206
+        vec![
207
+            format!("{candidate}@3x.png"),
208
+            format!("{candidate}@2x.png"),
209
+            format!("{candidate}.png"),
210
+            candidate.to_string(),
211
+        ]
212
+    };
213
+
214
+    reader
215
+        .file()
216
+        .entries()
217
+        .iter()
218
+        .enumerate()
219
+        .find_map(|(index, entry)| {
220
+            let name = entry_name(entry)?;
221
+            let relative = name.strip_prefix(app_prefix)?;
222
+            forms
223
+                .iter()
224
+                .any(|form| relative == form || relative.ends_with(&format!("/{form}")))
225
+                .then_some((index, name))
226
+        })
227
+}
228
+
229
+fn cache_icon(bundle_id: &str, bytes: &[u8]) -> Option<PathBuf> {
230
+    let mut path = app_data_dir()?.join("icons");
231
+    fs::create_dir_all(&path).ok()?;
232
+    path.push(format!("{}.png", cache_safe_name(bundle_id)));
233
+    fs::write(&path, bytes).ok()?;
234
+    Some(path)
235
+}
236
+
237
+fn cache_safe_name(value: &str) -> String {
238
+    value
239
+        .chars()
240
+        .map(|character| {
241
+            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
242
+                character
243
+            } else {
244
+                '_'
245
+            }
246
+        })
247
+        .collect()
248
+}
249
+
250
+async fn extract_entitlements(
251
+    reader: &ZipFileReader,
252
+    app_prefix: &str,
253
+) -> Option<Vec<AppEntitlement>> {
254
+    let entry_index = reader.file().entries().iter().position(|entry| {
255
+        entry_name(entry) == Some(&format!("{app_prefix}embedded.mobileprovision"))
256
+    })?;
257
+    let bytes = read_entry(reader, entry_index, "embedded.mobileprovision")
258
+        .await
259
+        .ok()?;
260
+    let plist = embedded_plist(&bytes)?;
261
+    let entitlements = plist.get("Entitlements")?.as_dictionary()?;
262
+    Some(entitlements_from_dictionary(entitlements))
263
+}
264
+
265
+fn embedded_plist(bytes: &[u8]) -> Option<Dictionary> {
266
+    let start = find_bytes(bytes, b"<?xml")?;
267
+    let end = find_bytes(&bytes[start..], b"</plist>")? + start + b"</plist>".len();
268
+    plist::from_reader(Cursor::new(&bytes[start..end])).ok()
269
+}
270
+
271
+fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
272
+    haystack
273
+        .windows(needle.len())
274
+        .position(|window| window == needle)
275
+}
276
+
277
+fn entitlements_from_dictionary(entitlements: &Dictionary) -> Vec<AppEntitlement> {
278
+    entitlements
279
+        .iter()
280
+        .map(|(key, value)| AppEntitlement {
281
+            key: key.clone().into(),
282
+            value: entitlement_value(value),
283
+        })
284
+        .collect()
285
+}
286
+
287
+fn entitlement_value(value: &Value) -> EntitlementValue {
288
+    match value {
289
+        Value::String(value) => EntitlementValue::String(value.clone().into()),
290
+        Value::Boolean(value) => EntitlementValue::Boolean(*value),
291
+        Value::Integer(value) => value
292
+            .as_signed()
293
+            .map(EntitlementValue::Integer)
294
+            .unwrap_or_else(|| EntitlementValue::String(value.to_string().into())),
295
+        Value::Real(value) => EntitlementValue::Number(*value),
296
+        Value::Array(values) => {
297
+            EntitlementValue::Array(values.iter().map(entitlement_value).collect())
298
+        }
299
+        Value::Dictionary(values) => EntitlementValue::Dictionary(
300
+            values
301
+                .iter()
302
+                .map(|(key, value)| (key.clone().into(), entitlement_value(value)))
303
+                .collect(),
304
+        ),
305
+        Value::Data(value) => EntitlementValue::Data(value.clone()),
306
+        Value::Date(value) => EntitlementValue::Date(value.to_xml_format().into()),
307
+        Value::Uid(value) => EntitlementValue::Uid(value.get()),
308
+        _ => EntitlementValue::Unknown("Value".into()),
309
+    }
310
+}
311
+
312
+fn fallback_entitlements(bundle_id: &str) -> Vec<AppEntitlement> {
313
+    vec![
314
+        AppEntitlement {
315
+            key: "application-identifier".into(),
316
+            value: EntitlementValue::String(format!("TEAMID.{bundle_id}").into()),
317
+        },
318
+        AppEntitlement {
319
+            key: "get-task-allow".into(),
320
+            value: EntitlementValue::Boolean(true),
321
+        },
322
+    ]
323
+}
... ...
@@ -1,23 +1,37 @@
1
+mod adi_backend;
2
+mod adi_services;
3
+mod app_selection;
1 4
 mod assets;
2 5
 mod constants;
3 6
 mod data;
7
+mod developer_accounts;
8
+mod device_discovery;
9
+mod device_selection;
10
+mod edit_line;
11
+mod ipa;
4 12
 mod main_view;
5 13
 mod models;
14
+mod paths;
15
+mod preferences;
6 16
 mod settings;
17
+mod system_identity;
7 18
 mod widgets;
8 19
 
9 20
 use assets::Assets;
10 21
 use constants::{WINDOW_HEIGHT, WINDOW_WIDTH};
11 22
 use gpui::{px, size, App, AppContext, Bounds, QuitMode, WindowBounds, WindowOptions};
23
+use log::LevelFilter;
12 24
 use main_view::SideloaderView;
13 25
 
14 26
 fn main() {
15
-    env_logger::init();
27
+    init_logging();
16 28
 
17 29
     gpui_platform::application()
18 30
         .with_assets(Assets)
19 31
         .with_quit_mode(QuitMode::LastWindowClosed)
20 32
         .run(|cx: &mut App| {
33
+            edit_line::init(cx);
34
+
21 35
             let window_size = size(px(WINDOW_WIDTH), px(WINDOW_HEIGHT));
22 36
             let bounds = Bounds::centered(None, window_size, cx);
23 37
 
... ...
@@ -38,3 +52,14 @@ fn main() {
38 52
             cx.activate(true);
39 53
         });
40 54
 }
55
+
56
+fn init_logging() {
57
+    let mut builder = env_logger::Builder::from_default_env();
58
+    builder
59
+        .filter_module("reqwest", LevelFilter::Info)
60
+        .filter_module("hyper", LevelFilter::Info)
61
+        .filter_module("h2", LevelFilter::Info)
62
+        .filter_module("rustls", LevelFilter::Info)
63
+        .filter_module("elf_loader", LevelFilter::Info);
64
+    builder.init();
65
+}
... ...
@@ -1,10 +1,17 @@
1
+use crate::adi_services;
2
+use crate::app_selection::{is_ipa_path, load_ipa, AppSelection};
1 3
 use crate::constants::*;
2
-use crate::data::{
3
-    sample_accounts, sample_adi_backends, sample_apps, sample_devices, sample_machine_identity,
4
-};
4
+use crate::data::{load_accounts, load_machine_identity};
5
+use crate::device_discovery::{discover_devices, watch_device_changes};
6
+use crate::device_selection::DeviceSelection;
5 7
 use crate::models::{
6
-    AccountOption, AdiBackendOption, AppOption, DeviceOption, MachineIdentity, PickerId,
7
-    SideloadOperation, SideloadPhase, TeamOption,
8
+    AccountOption, AdiBackendKind, AdiBackendOption, AppOption, DeviceOption, MachineIdentity,
9
+    PickerId, SideloadOperation, SideloadPhase, TeamOption,
10
+};
11
+use crate::preferences::{
12
+    load_preferences, save_preferences as save_preferences_to_disk, AdiPreferences,
13
+    AppOverridePreferences, AppPreferences, DeveloperPreferences, MachineIdentityPreferences,
14
+    SideloaderPreferences, StoredAdiBackendKind,
8 15
 };
9 16
 use crate::settings::{show_or_open_settings_window, SettingsWindow, SettingsWindowRequest};
10 17
 use crate::widgets::{
... ...
@@ -12,24 +19,29 @@ use crate::widgets::{
12 19
     developer_account_title, device_identity, dropdown_list, lucide_icon, menu_action_row,
13 20
     progress_circle, properties_list,
14 21
 };
22
+use futures::{channel::mpsc, StreamExt};
15 23
 use gpui::{
16
-    div, prelude::*, px, rgb, App, ClickEvent, Context, FocusHandle, FontWeight,
17
-    InteractiveElement, IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled,
18
-    Window, WindowHandle,
24
+    div, prelude::*, px, rgb, App, ClickEvent, Context, ExternalPaths, FocusHandle, FontWeight,
25
+    InteractiveElement, IntoElement, ParentElement, PathPromptOptions, Render,
26
+    StatefulInteractiveElement, Styled, Window, WindowHandle,
19 27
 };
28
+use rand::RngExt;
29
+use std::path::PathBuf;
20 30
 
21 31
 pub(crate) struct SideloaderView {
22 32
     pub(crate) focus_handle: FocusHandle,
23 33
     pub(crate) accounts: Vec<AccountOption>,
24
-    pub(crate) apps: Vec<AppOption>,
25
-    pub(crate) devices: Vec<DeviceOption>,
34
+    pub(crate) app_selection: AppSelection,
35
+    pub(crate) device_selection: DeviceSelection,
26 36
     pub(crate) adi_backends: Vec<AdiBackendOption>,
27 37
     pub(crate) selected_account: usize,
28 38
     pub(crate) selected_team: usize,
29
-    pub(crate) selected_app: usize,
30
-    pub(crate) selected_device: usize,
39
+    pub(crate) auto_app_id: bool,
40
+    pub(crate) selected_app_id: usize,
31 41
     pub(crate) selected_adi_backend: usize,
32 42
     pub(crate) machine_identity: MachineIdentity,
43
+    pub(crate) android_device_identity: MachineIdentity,
44
+    pub(crate) android_adi_identifier: String,
33 45
     pub(crate) open_picker: Option<PickerId>,
34 46
     pub(crate) enabled_patches: Vec<bool>,
35 47
     pub(crate) sideload_operation: SideloadOperation,
... ...
@@ -45,28 +57,74 @@ impl SideloaderView {
45 57
         cx.on_release(|view, cx| view.close_child_windows(cx))
46 58
             .detach();
47 59
 
48
-        let accounts = sample_accounts();
49
-        let apps = sample_apps();
50
-        let devices = sample_devices();
51
-        let adi_backends = sample_adi_backends();
52
-        let machine_identity = sample_machine_identity();
53
-        let enabled_patches = apps
54
-            .first()
60
+        let preferences = load_preferences().unwrap_or_else(|error| {
61
+            eprintln!("{error}");
62
+            SideloaderPreferences::default()
63
+        });
64
+        let accounts = load_accounts();
65
+        let app_selection = AppSelection::from_preferences(&preferences.app);
66
+        let machine_identity = load_machine_identity();
67
+        let mut android_device_uuid = preferences
68
+            .adi
69
+            .android_device
70
+            .machine_id
71
+            .clone()
72
+            .or_else(|| preferences.adi.android_device_uuid.clone())
73
+            .unwrap_or_default();
74
+        let generated_android_device_uuid = ensure_android_device_uuid(&mut android_device_uuid);
75
+        let mut android_device_identity =
76
+            android_device_identity_from_host(&machine_identity, android_device_uuid);
77
+        apply_machine_identity_preferences(
78
+            &mut android_device_identity,
79
+            &preferences.adi.android_device,
80
+        );
81
+        let mut android_adi_identifier = preferences
82
+            .adi
83
+            .android_adi_identifier
84
+            .clone()
85
+            .or_else(|| {
86
+                preferences
87
+                    .adi
88
+                    .android_machine
89
+                    .machine_id
90
+                    .clone()
91
+                    .filter(|identifier| identifier.len() == 16)
92
+            })
93
+            .unwrap_or_default();
94
+        let generated_android_adi_identifier =
95
+            ensure_android_adi_identifier(&mut android_adi_identifier);
96
+        let device_selection = DeviceSelection::new(preferences.device.as_ref());
97
+        let adi_backends = adi_services::available_backends(&android_adi_identifier);
98
+        let selected_account = selected_account_index(&accounts, &preferences.developer);
99
+        let selected_team =
100
+            selected_team_index(&accounts, selected_account, &preferences.developer);
101
+        let selected_app_id = selected_app_id_index(
102
+            &accounts,
103
+            selected_account,
104
+            selected_team,
105
+            &preferences.developer,
106
+        );
107
+        let selected_adi_backend =
108
+            selected_adi_backend_index(&adi_backends, preferences.adi.backend);
109
+        let enabled_patches = app_selection
110
+            .selected()
55 111
             .map(|app| vec![false; app.patches.len()])
56 112
             .unwrap_or_default();
57 113
 
58
-        Self {
114
+        let mut view = Self {
59 115
             focus_handle,
60 116
             accounts,
61
-            apps,
62
-            devices,
117
+            app_selection,
118
+            device_selection,
63 119
             adi_backends,
64
-            selected_account: 0,
65
-            selected_team: 0,
66
-            selected_app: 0,
67
-            selected_device: 0,
68
-            selected_adi_backend: 0,
120
+            selected_account,
121
+            selected_team,
122
+            auto_app_id: preferences.developer.auto_app_id,
123
+            selected_app_id,
124
+            selected_adi_backend,
69 125
             machine_identity,
126
+            android_device_identity,
127
+            android_adi_identifier,
70 128
             open_picker: None,
71 129
             enabled_patches,
72 130
             sideload_operation: SideloadOperation::Idle,
... ...
@@ -74,7 +132,13 @@ impl SideloaderView {
74 132
             team_settings_window: None,
75 133
             app_settings_window: None,
76 134
             adi_settings_window: None,
135
+        };
136
+        if generated_android_adi_identifier || generated_android_device_uuid {
137
+            view.save_preferences();
77 138
         }
139
+        view.start_device_refresh(cx);
140
+        view.start_device_events_watch(cx);
141
+        view
78 142
     }
79 143
 
80 144
     fn close_child_windows(&mut self, cx: &mut App) {
... ...
@@ -84,45 +148,168 @@ impl SideloaderView {
84 148
     }
85 149
 
86 150
     fn is_busy(&self) -> bool {
87
-        self.sideload_operation.is_busy()
151
+        self.sideload_operation.is_busy() || self.app_selection.is_loading()
88 152
     }
89 153
 
90
-    fn selected_account(&self) -> &AccountOption {
91
-        &self.accounts[self.selected_account]
154
+    pub(crate) fn save_preferences(&self) {
155
+        if let Err(error) = save_preferences_to_disk(&self.preferences()) {
156
+            eprintln!("{error}");
92 157
         }
93
-
94
-    fn selected_team(&self) -> &TeamOption {
95
-        &self.selected_account().teams[self.selected_team]
96 158
     }
97 159
 
98
-    fn selected_app(&self) -> &AppOption {
99
-        &self.apps[self.selected_app]
160
+    pub(crate) fn select_team_from_settings(
161
+        &mut self,
162
+        index: usize,
163
+        cx: &mut Context<Self>,
164
+    ) -> Option<gpui::SharedString> {
165
+        let team_count = self.accounts.get(self.selected_account)?.teams.len();
166
+        if index >= team_count {
167
+            return None;
100 168
         }
101 169
 
102
-    fn selected_device(&self) -> &DeviceOption {
103
-        &self.devices[self.selected_device]
170
+        self.selected_team = index;
171
+        self.selected_app_id = 0;
172
+        self.save_preferences();
173
+        cx.notify();
174
+        self.selected_team().map(|team| team.identifier.clone())
104 175
     }
105 176
 
106
-    fn select_account(
177
+    pub(crate) fn set_auto_app_id_from_settings(
107 178
         &mut self,
108
-        index: usize,
109
-        _: &ClickEvent,
110
-        window: &mut Window,
179
+        auto_app_id: bool,
111 180
         cx: &mut Context<Self>,
112 181
     ) {
113
-        window.focus(&self.focus_handle, cx);
114
-        if self.is_busy() {
115
-            return;
182
+        self.auto_app_id = auto_app_id;
183
+        self.save_preferences();
184
+        cx.notify();
116 185
     }
117
-        if index < self.accounts.len() {
118
-            self.selected_account = index;
186
+
187
+    pub(crate) fn select_app_id_from_settings(&mut self, index: usize, cx: &mut Context<Self>) {
188
+        self.selected_app_id = index;
189
+        self.save_preferences();
190
+        cx.notify();
191
+    }
192
+
193
+    pub(crate) fn add_developer_account_from_settings(
194
+        &mut self,
195
+        account: AccountOption,
196
+        cx: &mut Context<Self>,
197
+    ) -> Option<SettingsWindowRequest> {
198
+        self.accounts.push(account);
199
+        self.selected_account = self.accounts.len().saturating_sub(1);
119 200
         self.selected_team = 0;
201
+        self.selected_app_id = 0;
120 202
         self.open_picker = None;
121 203
         cx.notify();
204
+
205
+        let account = self.accounts.get(self.selected_account)?;
206
+        Some(SettingsWindowRequest::Team {
207
+            parent: cx.weak_entity(),
208
+            teams: account.teams.clone(),
209
+            selected_team: self.selected_team,
210
+            auto_app_id: self.auto_app_id,
211
+            selected_app_id: self.selected_app_id,
212
+        })
213
+    }
214
+
215
+    pub(crate) fn select_adi_backend_from_settings(
216
+        &mut self,
217
+        index: usize,
218
+        cx: &mut Context<Self>,
219
+    ) {
220
+        if index < self.adi_backends.len() {
221
+            self.selected_adi_backend = index;
222
+            self.save_preferences();
223
+            cx.notify();
224
+        }
225
+    }
226
+
227
+    pub(crate) fn replace_app_from_settings(
228
+        &mut self,
229
+        app_index: usize,
230
+        app: AppOption,
231
+        cx: &mut Context<Self>,
232
+    ) {
233
+        if self.app_selection.replace(app_index, app) {
234
+            self.save_preferences();
235
+            cx.notify();
236
+        }
237
+    }
238
+
239
+    pub(crate) fn replace_adi_backends_from_settings(
240
+        &mut self,
241
+        backends: Vec<AdiBackendOption>,
242
+        selected_backend: usize,
243
+        persist_selection: bool,
244
+        cx: &mut Context<Self>,
245
+    ) {
246
+        self.adi_backends = backends;
247
+        self.selected_adi_backend = selected_backend;
248
+        if persist_selection {
249
+            self.save_preferences();
122 250
         }
251
+        cx.notify();
123 252
     }
124 253
 
125
-    fn select_app(
254
+    pub(crate) fn replace_android_device_identity_from_settings(
255
+        &mut self,
256
+        identity: MachineIdentity,
257
+        cx: &mut Context<Self>,
258
+    ) {
259
+        self.android_device_identity = identity;
260
+        self.save_preferences();
261
+        cx.notify();
262
+    }
263
+
264
+    fn preferences(&self) -> SideloaderPreferences {
265
+        let account = self.accounts.get(self.selected_account);
266
+        let team = account.and_then(|account| account.teams.get(self.selected_team));
267
+        let app_id = team.and_then(|team| team.app_ids.get(self.selected_app_id));
268
+        let app = self.selected_app();
269
+        let backend = self.adi_backends.get(self.selected_adi_backend);
270
+
271
+        SideloaderPreferences {
272
+            developer: DeveloperPreferences {
273
+                account_id: account.map(|account| account.id.to_string()),
274
+                team_id: team.map(|team| team.identifier.to_string()),
275
+                auto_app_id: self.auto_app_id,
276
+                app_id: app_id.map(|app_id| app_id.identifier.to_string()),
277
+            },
278
+            app: AppPreferences {
279
+                bundle_id: app.map(|app| app.bundle_id().to_string()),
280
+                path: self.app_selection.selected_path_for_preferences(),
281
+                overrides: app.map(AppOverridePreferences::from).unwrap_or_default(),
282
+            },
283
+            device: self.device_selection.selected_preferences(),
284
+            adi: AdiPreferences {
285
+                backend: backend.map(|backend| StoredAdiBackendKind::from(backend.kind)),
286
+                machine: MachineIdentityPreferences::from(&self.machine_identity),
287
+                android_adi_identifier: Some(self.android_adi_identifier.clone()),
288
+                android_device: MachineIdentityPreferences::from(&self.android_device_identity),
289
+                android_device_uuid: Some(self.android_device_identity.machine_id.to_string()),
290
+                android_machine: MachineIdentityPreferences::default(),
291
+            },
292
+        }
293
+    }
294
+
295
+    fn selected_account(&self) -> Option<&AccountOption> {
296
+        self.accounts.get(self.selected_account)
297
+    }
298
+
299
+    fn selected_team(&self) -> Option<&TeamOption> {
300
+        self.selected_account()
301
+            .and_then(|account| account.teams.get(self.selected_team))
302
+    }
303
+
304
+    fn selected_app(&self) -> Option<&AppOption> {
305
+        self.app_selection.selected()
306
+    }
307
+
308
+    fn selected_device(&self) -> Option<&DeviceOption> {
309
+        self.device_selection.selected()
310
+    }
311
+
312
+    fn select_account(
126 313
         &mut self,
127 314
         index: usize,
128 315
         _: &ClickEvent,
... ...
@@ -133,10 +320,12 @@ impl SideloaderView {
133 320
         if self.is_busy() {
134 321
             return;
135 322
         }
136
-        if index < self.apps.len() {
137
-            self.selected_app = index;
138
-            self.enabled_patches = vec![false; self.selected_app().patches.len()];
323
+        if index < self.accounts.len() {
324
+            self.selected_account = index;
325
+            self.selected_team = 0;
326
+            self.selected_app_id = 0;
139 327
             self.open_picker = None;
328
+            self.save_preferences();
140 329
             cx.notify();
141 330
         }
142 331
     }
... ...
@@ -152,9 +341,9 @@ impl SideloaderView {
152 341
         if self.is_busy() {
153 342
             return;
154 343
         }
155
-        if index < self.devices.len() {
156
-            self.selected_device = index;
344
+        if self.device_selection.select(index) {
157 345
             self.open_picker = None;
346
+            self.save_preferences();
158 347
             cx.notify();
159 348
         }
160 349
     }
... ...
@@ -165,6 +354,16 @@ impl SideloaderView {
165 354
             return;
166 355
         }
167 356
         self.open_picker = None;
357
+        let request = SettingsWindowRequest::DeveloperLogin {
358
+            parent: cx.weak_entity(),
359
+        };
360
+        self.team_settings_window = Some(show_or_open_settings_window(
361
+            self.team_settings_window,
362
+            request,
363
+            SETTINGS_WINDOW_WIDTH,
364
+            TEAM_SETTINGS_WINDOW_HEIGHT,
365
+            cx,
366
+        ));
168 367
         cx.notify();
169 368
     }
170 369
 
... ...
@@ -174,14 +373,103 @@ impl SideloaderView {
174 373
             return;
175 374
         }
176 375
         self.open_picker = None;
376
+        let receiver = cx.prompt_for_paths(PathPromptOptions {
377
+            files: true,
378
+            directories: false,
379
+            multiple: false,
380
+            prompt: Some("Select IPA".into()),
381
+        });
382
+
383
+        cx.spawn(async move |view, cx| {
384
+            let paths = match receiver.await {
385
+                Ok(Ok(Some(paths))) => paths,
386
+                Ok(Ok(None)) | Err(_) => return,
387
+                Ok(Err(error)) => {
388
+                    eprintln!("Failed to select IPA: {error}");
389
+                    return;
390
+                }
391
+            };
392
+
393
+            let Some(path) = paths.into_iter().next() else {
394
+                return;
395
+            };
396
+
397
+            let _ = view.update(cx, |view, cx| {
398
+                view.select_ipa_path(path, cx);
399
+            });
400
+        })
401
+        .detach();
177 402
         cx.notify();
178 403
     }
179 404
 
180
-    fn sideload(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
405
+    fn drop_ipa(&mut self, paths: &ExternalPaths, window: &mut Window, cx: &mut Context<Self>) {
181 406
         window.focus(&self.focus_handle, cx);
407
+        cx.stop_propagation();
182 408
         if self.is_busy() {
183 409
             return;
184 410
         }
411
+        if let Some(path) = first_ipa_path(paths) {
412
+            self.select_ipa_path(path, cx);
413
+        }
414
+    }
415
+
416
+    fn select_ipa_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
417
+        if !is_ipa_path(&path) {
418
+            let path = path.to_string_lossy().to_string();
419
+            self.app_selection.fail(
420
+                path.clone(),
421
+                format!("Selected file is not an IPA archive: {path}"),
422
+            );
423
+            self.enabled_patches.clear();
424
+            self.save_preferences();
425
+            cx.notify();
426
+            return;
427
+        }
428
+
429
+        let path_string = path.to_string_lossy().to_string();
430
+        if self.app_selection.select_cached_path(&path_string) {
431
+            self.enabled_patches = self
432
+                .selected_app()
433
+                .map(|app| vec![false; app.patches.len()])
434
+                .unwrap_or_default();
435
+            self.open_picker = None;
436
+            self.save_preferences();
437
+            cx.notify();
438
+            return;
439
+        }
440
+
441
+        let patches = self
442
+            .selected_app()
443
+            .map(|app| app.patches.clone())
444
+            .unwrap_or_default();
445
+        self.app_selection.begin_loading(path_string);
446
+        self.open_picker = None;
447
+        cx.notify();
448
+
449
+        cx.spawn(async move |view, cx| {
450
+            let path_for_error = path.clone();
451
+            let result = cx
452
+                .background_spawn(async move { load_ipa(path, patches.clone()) })
453
+                .await;
454
+
455
+            let _ = view.update(cx, |view, cx| {
456
+                view.app_selection.finish_loading(&path_for_error, result);
457
+                view.enabled_patches = view
458
+                    .selected_app()
459
+                    .map(|app| vec![false; app.patches.len()])
460
+                    .unwrap_or_default();
461
+                view.save_preferences();
462
+                cx.notify();
463
+            });
464
+        })
465
+        .detach();
466
+    }
467
+
468
+    fn sideload(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
469
+        window.focus(&self.focus_handle, cx);
470
+        if self.is_busy() || self.selected_app().is_none() || self.selected_device().is_none() {
471
+            return;
472
+        }
185 473
         self.open_picker = None;
186 474
         self.sideload_operation = SideloadOperation::Running {
187 475
             phase: SideloadPhase::Signing,
... ...
@@ -190,17 +478,27 @@ impl SideloaderView {
190 478
         cx.notify();
191 479
     }
192 480
 
193
-    fn toggle_account_picker(
481
+    fn refresh_devices_from_button(
194 482
         &mut self,
195 483
         _: &ClickEvent,
196 484
         window: &mut Window,
197 485
         cx: &mut Context<Self>,
198 486
     ) {
199
-        self.toggle_picker(PickerId::Account, window, cx);
487
+        window.focus(&self.focus_handle, cx);
488
+        if self.is_busy() {
489
+            return;
490
+        }
491
+        self.start_device_events_watch(cx);
492
+        self.start_device_refresh(cx);
200 493
     }
201 494
 
202
-    fn toggle_app_picker(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
203
-        self.toggle_picker(PickerId::App, window, cx);
495
+    fn toggle_account_picker(
496
+        &mut self,
497
+        _: &ClickEvent,
498
+        window: &mut Window,
499
+        cx: &mut Context<Self>,
500
+    ) {
501
+        self.toggle_picker(PickerId::Account, window, cx);
204 502
     }
205 503
 
206 504
     fn toggle_device_picker(
... ...
@@ -230,14 +528,21 @@ impl SideloaderView {
230 528
         if self.is_busy() {
231 529
             return;
232 530
         }
531
+        let Some(account) = self.selected_account() else {
532
+            return;
533
+        };
534
+        if account.teams.is_empty() || self.selected_team().is_none() {
535
+            return;
536
+        }
537
+        let teams = account.teams.clone();
233 538
         self.open_picker = None;
234 539
         let parent = cx.weak_entity();
235 540
         let request = SettingsWindowRequest::Team {
236 541
             parent,
237
-            teams: self.selected_account().teams.clone(),
542
+            teams,
238 543
             selected_team: self.selected_team,
239
-            auto_app_id: true,
240
-            selected_app_id: 0,
544
+            auto_app_id: self.auto_app_id,
545
+            selected_app_id: self.selected_app_id,
241 546
         };
242 547
         self.team_settings_window = Some(show_or_open_settings_window(
243 548
             self.team_settings_window,
... ...
@@ -249,17 +554,93 @@ impl SideloaderView {
249 554
         cx.notify();
250 555
     }
251 556
 
557
+    fn start_device_refresh(&mut self, cx: &mut Context<Self>) {
558
+        let Some(generation) = self.device_selection.begin_refresh() else {
559
+            return;
560
+        };
561
+        cx.notify();
562
+
563
+        cx.spawn(async move |view, cx| {
564
+            let result = cx.background_spawn(async move { discover_devices() }).await;
565
+            let _ = view.update(cx, |view, cx| {
566
+                view.finish_device_refresh(generation, result);
567
+                cx.notify();
568
+            });
569
+        })
570
+        .detach();
571
+    }
572
+
573
+    fn start_device_events_watch(&mut self, cx: &mut Context<Self>) {
574
+        if !self.device_selection.start_events_watch() {
575
+            return;
576
+        }
577
+
578
+        let (sender, mut receiver) = mpsc::unbounded();
579
+        cx.background_spawn(async move { watch_device_changes(sender) })
580
+            .detach();
581
+
582
+        cx.spawn(async move |view, cx| {
583
+            while let Some(result) = receiver.next().await {
584
+                let should_continue = view
585
+                    .update(cx, |view, cx| match result {
586
+                        Ok(()) => {
587
+                            view.device_selection.note_device_event();
588
+                            if !view.is_busy() && !view.device_selection.is_refreshing() {
589
+                                view.start_device_refresh(cx);
590
+                            }
591
+                            true
592
+                        }
593
+                        Err(error) => {
594
+                            view.device_selection.fail_events_watch(error);
595
+                            cx.notify();
596
+                            false
597
+                        }
598
+                    })
599
+                    .unwrap_or(false);
600
+
601
+                if !should_continue {
602
+                    return;
603
+                }
604
+            }
605
+
606
+            let _ = view.update(cx, |view, cx| {
607
+                view.device_selection.finish_events_watch();
608
+                cx.notify();
609
+            });
610
+        })
611
+        .detach();
612
+    }
613
+
614
+    fn finish_device_refresh(
615
+        &mut self,
616
+        generation: u64,
617
+        result: Result<Vec<DeviceOption>, String>,
618
+    ) {
619
+        self.device_selection.finish_refresh(generation, result);
620
+        if self.device_selection.is_empty() && self.open_picker == Some(PickerId::Device) {
621
+            self.open_picker = None;
622
+        }
623
+    }
624
+
252 625
     fn open_app_settings(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
253 626
         window.focus(&self.focus_handle, cx);
254 627
         if self.is_busy() {
255 628
             return;
256 629
         }
630
+        let Some(app) = self.selected_app().cloned() else {
631
+            return;
632
+        };
257 633
         self.open_picker = None;
258 634
         let parent = cx.weak_entity();
259 635
         let request = SettingsWindowRequest::AppSettings {
260
-            _parent: parent,
261
-            app: self.selected_app().clone(),
636
+            parent,
637
+            app_index: self.app_selection.selected_index(),
638
+            app,
262 639
             enabled_patches: self.enabled_patches.clone(),
640
+            team_id: self
641
+                .selected_team()
642
+                .map(|team| team.identifier.clone())
643
+                .unwrap_or_default(),
263 644
         };
264 645
         self.app_settings_window = Some(show_or_open_settings_window(
265 646
             self.app_settings_window,
... ...
@@ -274,12 +655,15 @@ impl SideloaderView {
274 655
     fn open_adi_settings(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
275 656
         window.focus(&self.focus_handle, cx);
276 657
         self.open_picker = None;
658
+        self.refresh_adi_backends();
277 659
         let parent = cx.weak_entity();
278 660
         let request = SettingsWindowRequest::AdiSettings {
279 661
             parent,
280 662
             backends: self.adi_backends.clone(),
281 663
             selected_backend: self.selected_adi_backend,
282 664
             machine_identity: self.machine_identity.clone(),
665
+            android_device_identity: self.android_device_identity.clone(),
666
+            android_adi_identifier: self.android_adi_identifier.clone(),
283 667
         };
284 668
         self.adi_settings_window = Some(show_or_open_settings_window(
285 669
             self.adi_settings_window,
... ...
@@ -291,9 +675,26 @@ impl SideloaderView {
291 675
         cx.notify();
292 676
     }
293 677
 
678
+    pub(crate) fn refresh_adi_backends(&mut self) {
679
+        let selected_kind = self
680
+            .adi_backends
681
+            .get(self.selected_adi_backend)
682
+            .map(|backend| backend.kind);
683
+        self.adi_backends = adi_services::available_backends(&self.android_adi_identifier);
684
+        self.selected_adi_backend = selected_kind
685
+            .and_then(|kind| {
686
+                self.adi_backends
687
+                    .iter()
688
+                    .position(|backend| backend.kind == kind)
689
+            })
690
+            .unwrap_or_else(|| adi_services::default_backend(&self.adi_backends));
691
+    }
692
+
294 693
     fn enabled_patch_names(&self) -> Vec<String> {
295
-        self.selected_app()
296
-            .patches
694
+        let Some(app) = self.selected_app() else {
695
+            return Vec::new();
696
+        };
697
+        app.patches
297 698
             .iter()
298 699
             .enumerate()
299 700
             .filter_map(|(index, patch)| {
... ...
@@ -310,8 +711,22 @@ impl SideloaderView {
310 711
         number: &'static str,
311 712
         title: &'static str,
312 713
         detail: &'static str,
313
-    ) -> impl IntoElement {
314
-        div().flex().items_start().justify_between().gap_3().child(
714
+    ) -> gpui::Div {
715
+        Self::section_header_with_action(number, title, detail, div())
716
+    }
717
+
718
+    fn section_header_with_action(
719
+        number: &'static str,
720
+        title: &'static str,
721
+        detail: &'static str,
722
+        action: impl IntoElement,
723
+    ) -> gpui::Div {
724
+        div()
725
+            .flex()
726
+            .items_start()
727
+            .justify_between()
728
+            .gap_3()
729
+            .child(
315 730
                 div()
316 731
                     .flex()
317 732
                     .items_center()
... ...
@@ -345,6 +760,7 @@ impl SideloaderView {
345 760
                             .child(div().text_xs().text_color(rgb(0x6a7a81)).child(detail)),
346 761
                     ),
347 762
             )
763
+            .child(action)
348 764
     }
349 765
 
350 766
     fn settings_button(label: &'static str, detail: &'static str) -> gpui::Div {
... ...
@@ -427,46 +843,12 @@ impl SideloaderView {
427 843
             ))
428 844
     }
429 845
 
430
-    fn app_row(&self, index: usize, cx: &mut Context<Self>) -> impl IntoElement {
431
-        let app = &self.apps[index];
432
-        let selected = index == self.selected_app;
433
-        let border = if selected {
434
-            rgb(0x0f6f7a)
435
-        } else {
436
-            rgb(0xd8e0df)
437
-        };
438
-
439
-        div()
440
-            .h(px(COMBO_ITEM_HEIGHT))
441
-            .px_3()
442
-            .rounded_md()
443
-            .border_1()
444
-            .border_color(border)
445
-            .bg(if selected {
446
-                rgb(0xf0fbfa)
447
-            } else {
448
-                rgb(0xffffff)
449
-            })
450
-            .cursor_pointer()
451
-            .hover(|style| style.bg(rgb(0xf6f9f9)))
452
-            .id(("app-row", index))
453
-            .on_click(
454
-                cx.listener(move |this, event, window, cx| {
455
-                    this.select_app(index, event, window, cx)
456
-                }),
457
-            )
458
-            .flex()
459
-            .items_center()
460
-            .child(combo_item_content(
461
-                app.path.clone(),
462
-                app.name.clone(),
463
-                app_identity(app),
464
-            ))
465
-    }
466
-
467 846
     fn device_row(&self, index: usize, cx: &mut Context<Self>) -> impl IntoElement {
468
-        let device = &self.devices[index];
469
-        let selected = index == self.selected_device;
847
+        let device = self
848
+            .device_selection
849
+            .device(index)
850
+            .expect("device row index must be valid");
851
+        let selected = index == self.device_selection.selected_index();
470 852
         let border = if selected {
471 853
             rgb(0x0f6f7a)
472 854
         } else {
... ...
@@ -522,12 +904,21 @@ impl SideloaderView {
522 904
     }
523 905
 
524 906
     fn account_picker(&self, cx: &mut Context<Self>) -> impl IntoElement {
525
-        let account = self.selected_account();
907
+        let body = if let Some(account) = self.selected_account() {
526 908
             let team = self.selected_team();
527 909
             let is_open = self.open_picker == Some(PickerId::Account);
910
+            let detail = team
911
+                .map(|team| developer_account_title(&team.role))
912
+                .unwrap_or_else(|| account.status.clone());
913
+            let mut properties = vec![
914
+                ("Apple Account", account.apple_id.to_string()),
915
+                ("Status", account.status.to_string()),
916
+                ("Source", account.detail.to_string()),
917
+            ];
918
+            if let Some(team) = team {
919
+                properties.push(("Team", team.name.to_string()));
920
+            }
528 921
 
529
-        Self::picker_shell(
530
-            Self::section_header("1", "Account", "Apple ID and developer membership"),
531 922
             div()
532 923
                 .flex()
533 924
                 .flex_col()
... ...
@@ -537,7 +928,7 @@ impl SideloaderView {
537 928
                     combo_button(
538 929
                         account.apple_id.clone(),
539 930
                         account.label.clone(),
540
-                        developer_account_title(&team.role),
931
+                        detail,
541 932
                         is_open,
542 933
                     )
543 934
                     .id("account-combobox")
... ...
@@ -548,35 +939,42 @@ impl SideloaderView {
548 939
                     )
549 940
                     .child(
550 941
                         menu_action_row(
551
-                            "Manage Accounts...",
552
-                            "Add, remove, or refresh Apple ID sessions.",
942
+                            "Add an Apple Account",
943
+                            "Log in to another developer Apple Account.",
553 944
                         )
554 945
                         .id("manage-accounts")
555 946
                         .on_click(cx.listener(Self::manage_accounts)),
556 947
                     )
557 948
                     .id("account-options"),
558 949
                 ))
559
-                .child(properties_list(vec![
560
-                    ("Apple ID", account.apple_id.to_string()),
561
-                    ("Status", account.status.to_string()),
562
-                    ("Source", account.detail.to_string()),
563
-                    ("Team", team.name.to_string()),
564
-                ]))
565
-                .child(
950
+                .child(properties_list(properties))
951
+                .when(team.is_some(), |this| {
952
+                    this.child(
566 953
                         Self::settings_button(
567 954
                             "Advanced Settings",
568 955
                             "Select the developer team for signing.",
569 956
                         )
570 957
                         .id("team-disclosure")
571 958
                         .on_click(cx.listener(Self::open_team_settings)),
572
-                ),
959
+                    )
960
+                })
961
+        } else {
962
+            div()
963
+                .flex()
964
+                .flex_col()
965
+                .gap_4()
966
+                .child(add_apple_account_button(cx))
967
+        };
968
+
969
+        Self::picker_shell(
970
+            Self::section_header("1", "Account", "Apple Account and developer membership"),
971
+            body,
573 972
             self.is_busy(),
574 973
         )
575 974
     }
576 975
 
577 976
     fn app_picker(&self, cx: &mut Context<Self>) -> impl IntoElement {
578 977
         let app = self.selected_app();
579
-        let is_open = self.open_picker == Some(PickerId::App);
580 978
         let patch_summary = self.enabled_patch_names();
581 979
         let patch_summary = if patch_summary.is_empty() {
582 980
             "No patches enabled".to_string()
... ...
@@ -584,55 +982,157 @@ impl SideloaderView {
584 982
             patch_summary.join(", ")
585 983
         };
586 984
 
587
-        Self::picker_shell(
588
-            Self::section_header("2", "App", "IPA selection and patch profile"),
589
-            div()
985
+        let body = div()
590 986
             .flex()
591 987
             .flex_col()
592 988
             .gap_4()
593
-                .child(combo_with_popover(
594
-                    "app-popover-scroll",
595
-                    combo_button(
596
-                        app.path.clone(),
597
-                        app.name.clone(),
598
-                        app_identity(app),
599
-                        is_open,
600
-                    )
601
-                    .id("app-combobox")
602
-                    .on_click(cx.listener(Self::toggle_app_picker)),
603
-                    is_open,
604
-                    dropdown_list((0..self.apps.len()).map(|index| self.app_row(index, cx)))
605
-                        .child(
606
-                            menu_action_row("Choose IPA...", "Browse for another IPA archive.")
607
-                                .id("choose-ipa")
608
-                                .on_click(cx.listener(Self::choose_ipa)),
989
+            .child(self.ipa_button(app, cx))
990
+            .when_some(self.app_selection.load_error(), |this, error| {
991
+                this.child(
992
+                    div()
993
+                        .rounded_md()
994
+                        .border_1()
995
+                        .border_color(rgb(0xe5b8b2))
996
+                        .bg(rgb(0xfff5f3))
997
+                        .p_3()
998
+                        .text_sm()
999
+                        .text_color(rgb(0x9a302b))
1000
+                        .child(error.to_string()),
609 1001
                 )
610
-                        .id("app-options"),
611
-                ))
612
-                .child(properties_list(vec![
613
-                    ("Bundle ID", app.bundle_id.to_string()),
614
-                    ("Version", format!("{} ({})", app.version, app.build)),
1002
+            });
1003
+
1004
+        let body = if let Some(app) = app {
1005
+            body.child(properties_list(vec![
1006
+                ("Bundle ID", app.bundle_id().to_string()),
1007
+                ("Version", format!("{} ({})", app.version(), app.build())),
615 1008
                 ("IPA", app.path.to_string()),
616 1009
                 ("Patches", patch_summary),
617 1010
             ]))
618 1011
             .child(
619
-                    Self::settings_button(
620
-                        "Advanced Settings",
621
-                        "Edit app metadata and entitlements.",
622
-                    )
1012
+                Self::settings_button("Advanced Settings", "Edit app metadata and entitlements.")
623 1013
                     .id("app-disclosure")
624 1014
                     .on_click(cx.listener(Self::open_app_settings)),
625
-                ),
1015
+            )
1016
+        } else {
1017
+            body
1018
+        };
1019
+
1020
+        Self::picker_shell(
1021
+            Self::section_header("2", "App", "IPA selection and patch profile"),
1022
+            body,
626 1023
             self.is_busy(),
627 1024
         )
628 1025
     }
629 1026
 
1027
+    fn ipa_button(&self, app: Option<&AppOption>, cx: &mut Context<Self>) -> impl IntoElement {
1028
+        let disabled = self.is_busy();
1029
+        let (label, title, detail) = if let Some(path) = self.app_selection.loading_path() {
1030
+            (
1031
+                path.to_string().into(),
1032
+                "Loading IPA...".into(),
1033
+                "Reading metadata, icon, and entitlements.".into(),
1034
+            )
1035
+        } else if let Some(app) = app {
1036
+            (app.path.clone(), app.name().clone(), app_identity(app))
1037
+        } else if let Some(path) = self.app_selection.error_path() {
1038
+            (
1039
+                path.to_string().into(),
1040
+                "Failed to load IPA".into(),
1041
+                "Select another IPA archive.".into(),
1042
+            )
1043
+        } else {
1044
+            (
1045
+                "No IPA selected".into(),
1046
+                "Choose IPA...".into(),
1047
+                "Select or drop an IPA archive.".into(),
1048
+            )
1049
+        };
1050
+
1051
+        div()
1052
+            .id("ipa-button")
1053
+            .h(px(COMBO_ITEM_HEIGHT))
1054
+            .px_3()
1055
+            .rounded_md()
1056
+            .border_1()
1057
+            .border_color(rgb(0xcfd8d6))
1058
+            .bg(rgb(0xffffff))
1059
+            .flex()
1060
+            .items_center()
1061
+            .gap_3()
1062
+            .can_drop(|dragged, _, _| {
1063
+                dragged
1064
+                    .downcast_ref::<ExternalPaths>()
1065
+                    .is_some_and(paths_include_ipa)
1066
+            })
1067
+            .drag_over::<ExternalPaths>(|this, paths, _, _| {
1068
+                if paths_include_ipa(paths) {
1069
+                    this.border_color(rgb(0x0f6f7a)).bg(rgb(0xf0fbfa))
1070
+                } else {
1071
+                    this
1072
+                }
1073
+            })
1074
+            .when(!disabled, |this| {
1075
+                this.cursor_pointer()
1076
+                    .hover(|style| style.bg(rgb(0xf6f9f9)))
1077
+                    .on_click(cx.listener(Self::choose_ipa))
1078
+                    .on_drop(cx.listener(Self::drop_ipa))
1079
+            })
1080
+            .child(combo_item_content(label, title, detail))
1081
+            .child(
1082
+                div()
1083
+                    .flex_none()
1084
+                    .w_7()
1085
+                    .h_7()
1086
+                    .rounded_md()
1087
+                    .bg(rgb(0xebf1f0))
1088
+                    .text_color(rgb(0x53666d))
1089
+                    .flex()
1090
+                    .items_center()
1091
+                    .justify_center()
1092
+                    .child(lucide_icon("icons/folder-open.svg")),
1093
+            )
1094
+    }
1095
+
1096
+    fn device_refresh_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
1097
+        div()
1098
+            .id("refresh-devices")
1099
+            .w_7()
1100
+            .h_7()
1101
+            .rounded_md()
1102
+            .bg(rgb(0xebf1f0))
1103
+            .text_color(rgb(0x53666d))
1104
+            .flex()
1105
+            .items_center()
1106
+            .justify_center()
1107
+            .when(
1108
+                !self.is_busy() && !self.device_selection.is_refreshing(),
1109
+                |this| {
1110
+                    this.cursor_pointer()
1111
+                        .hover(|style| style.bg(rgb(0xdfe8e6)))
1112
+                        .on_click(cx.listener(Self::refresh_devices_from_button))
1113
+                },
1114
+            )
1115
+            .when(self.is_busy(), |this| this.opacity(0.55))
1116
+            .child(if self.device_selection.is_refreshing() {
1117
+                div()
1118
+                    .w_4()
1119
+                    .h_4()
1120
+                    .child(progress_circle(0.34, self.spinner_turns))
1121
+            } else {
1122
+                div().child(lucide_icon("icons/refresh-cw.svg"))
1123
+            })
1124
+    }
1125
+
630 1126
     fn device_picker(&self, cx: &mut Context<Self>) -> impl IntoElement {
631
-        let device = self.selected_device();
632 1127
         let is_open = self.open_picker == Some(PickerId::Device);
1128
+        let header = Self::section_header_with_action(
1129
+            "3",
1130
+            "Device",
1131
+            "Install target",
1132
+            self.device_refresh_button(cx),
1133
+        );
633 1134
 
634
-        Self::picker_shell(
635
-            Self::section_header("3", "Device", "Install target"),
1135
+        let body = if let Some(device) = self.selected_device() {
636 1136
             div()
637 1137
                 .flex()
638 1138
                 .flex_col()
... ...
@@ -646,9 +1146,13 @@ impl SideloaderView {
646 1146
                         is_open,
647 1147
                     )
648 1148
                     .id("device-combobox")
649
-                    .on_click(cx.listener(Self::toggle_device_picker)),
1149
+                    .when(!self.device_selection.is_refreshing(), |this| {
1150
+                        this.on_click(cx.listener(Self::toggle_device_picker))
1151
+                    }),
650 1152
                     is_open,
651
-                    dropdown_list((0..self.devices.len()).map(|index| self.device_row(index, cx)))
1153
+                    dropdown_list(
1154
+                        (0..self.device_selection.len()).map(|index| self.device_row(index, cx)),
1155
+                    )
652 1156
                     .id("device-options"),
653 1157
                 ))
654 1158
                 .child(properties_list(vec![
... ...
@@ -656,9 +1160,41 @@ impl SideloaderView {
656 1160
                     ("OS", device.os.to_string()),
657 1161
                     ("UDID", device.udid.to_string()),
658 1162
                     ("Connection", device.connection.to_string()),
659
-                ])),
660
-            self.is_busy(),
1163
+                ]))
1164
+        } else {
1165
+            let status = if self.device_selection.is_refreshing() {
1166
+                "Scanning for devices".to_string()
1167
+            } else if let Some(error) = self.device_selection.refresh_error() {
1168
+                error.to_string()
1169
+            } else {
1170
+                "No devices found".to_string()
1171
+            };
1172
+            let detail = if self.device_selection.is_refreshing() {
1173
+                "Looking for connected iPhone, iPad, Apple TV, or Apple Watch.".to_string()
1174
+            } else {
1175
+                "Connect a device, then refresh.".to_string()
1176
+            };
1177
+
1178
+            div()
1179
+                .flex()
1180
+                .flex_col()
1181
+                .gap_4()
1182
+                .child(
1183
+                    div()
1184
+                        .h(px(COMBO_ITEM_HEIGHT))
1185
+                        .px_3()
1186
+                        .rounded_md()
1187
+                        .border_1()
1188
+                        .border_color(rgb(0xcfd8d6))
1189
+                        .bg(rgb(0xffffff))
1190
+                        .flex()
1191
+                        .items_center()
1192
+                        .child(combo_item_content("No UDID", "No device connected", detail)),
661 1193
                 )
1194
+                .child(properties_list(vec![("Status", status)]))
1195
+        };
1196
+
1197
+        Self::picker_shell(header, body, self.is_busy())
662 1198
     }
663 1199
 
664 1200
     fn plus_connector(disabled: bool) -> impl IntoElement {
... ...
@@ -688,12 +1224,14 @@ impl SideloaderView {
688 1224
 
689 1225
     fn sideload_connector(&self, cx: &mut Context<Self>) -> impl IntoElement {
690 1226
         let busy = self.sideload_operation.is_busy();
1227
+        let selected_app = self.selected_app();
1228
+        let can_sideload = selected_app.is_some() && self.selected_device().is_some();
691 1229
         let progress = self.sideload_operation.progress();
692 1230
         let track_fill_width = SIDELOAD_BUTTON_WIDTH * progress;
693 1231
         let status_color = self.sideload_operation.status_color();
694 1232
         let status_text = self
695 1233
             .sideload_operation
696
-            .status_text(self.selected_app(), self.selected_device());
1234
+            .status_text(selected_app, self.selected_device());
697 1235
 
698 1236
         div()
699 1237
             .w(px(SIDELOAD_CONNECTOR_WIDTH))
... ...
@@ -733,11 +1271,12 @@ impl SideloaderView {
733 1271
                             .items_center()
734 1272
                             .justify_center()
735 1273
                             .gap_1()
736
-                            .when(!busy, |this| {
1274
+                            .when(!busy && can_sideload, |this| {
737 1275
                                 this.cursor_pointer()
738 1276
                                     .hover(|style| style.bg(rgb(0x20545c)))
739 1277
                                     .on_click(cx.listener(Self::sideload))
740 1278
                             })
1279
+                            .when(!can_sideload, |this| this.opacity(0.7))
741 1280
                             .when(busy, |this| {
742 1281
                                 this.child(
743 1282
                                     div()
... ...
@@ -826,9 +1365,197 @@ fn close_settings_window(handle: Option<WindowHandle<SettingsWindow>>, cx: &mut
826 1365
     }
827 1366
 }
828 1367
 
1368
+fn add_apple_account_button(cx: &mut Context<SideloaderView>) -> impl IntoElement {
1369
+    div()
1370
+        .id("add-apple-account")
1371
+        .h(px(COMBO_ITEM_HEIGHT))
1372
+        .px_3()
1373
+        .rounded_md()
1374
+        .border_1()
1375
+        .border_color(rgb(0xcfd8d6))
1376
+        .bg(rgb(0xffffff))
1377
+        .cursor_pointer()
1378
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
1379
+        .flex()
1380
+        .items_center()
1381
+        .justify_between()
1382
+        .gap_3()
1383
+        .on_click(cx.listener(SideloaderView::manage_accounts))
1384
+        .child(
1385
+            div()
1386
+                .min_w_0()
1387
+                .flex()
1388
+                .flex_1()
1389
+                .flex_col()
1390
+                .gap_0p5()
1391
+                .child(
1392
+                    div()
1393
+                        .text_sm()
1394
+                        .font_weight(FontWeight::SEMIBOLD)
1395
+                        .text_color(rgb(0x24333a))
1396
+                        .child("Add an Apple Account"),
1397
+                )
1398
+                .child(
1399
+                    div()
1400
+                        .text_xs()
1401
+                        .text_color(rgb(0x7b8a90))
1402
+                        .child("Log in before choosing a developer team."),
1403
+                ),
1404
+        )
1405
+        .child(
1406
+            div()
1407
+                .flex_none()
1408
+                .w_7()
1409
+                .h_7()
1410
+                .rounded_md()
1411
+                .bg(rgb(0xebf1f0))
1412
+                .text_color(rgb(0x53666d))
1413
+                .flex()
1414
+                .items_center()
1415
+                .justify_center()
1416
+                .child(lucide_icon("icons/plus.svg")),
1417
+        )
1418
+}
1419
+
1420
+fn selected_account_index(accounts: &[AccountOption], preferences: &DeveloperPreferences) -> usize {
1421
+    preferences
1422
+        .account_id
1423
+        .as_deref()
1424
+        .and_then(|account_id| {
1425
+            accounts
1426
+                .iter()
1427
+                .position(|account| account.id.as_ref() == account_id)
1428
+        })
1429
+        .unwrap_or(0)
1430
+}
1431
+
1432
+fn selected_team_index(
1433
+    accounts: &[AccountOption],
1434
+    selected_account: usize,
1435
+    preferences: &DeveloperPreferences,
1436
+) -> usize {
1437
+    preferences
1438
+        .team_id
1439
+        .as_deref()
1440
+        .and_then(|team_id| {
1441
+            accounts
1442
+                .get(selected_account)?
1443
+                .teams
1444
+                .iter()
1445
+                .position(|team| team.identifier.as_ref() == team_id)
1446
+        })
1447
+        .unwrap_or(0)
1448
+}
1449
+
1450
+fn selected_app_id_index(
1451
+    accounts: &[AccountOption],
1452
+    selected_account: usize,
1453
+    selected_team: usize,
1454
+    preferences: &DeveloperPreferences,
1455
+) -> usize {
1456
+    preferences
1457
+        .app_id
1458
+        .as_deref()
1459
+        .and_then(|app_id| {
1460
+            accounts
1461
+                .get(selected_account)?
1462
+                .teams
1463
+                .get(selected_team)?
1464
+                .app_ids
1465
+                .iter()
1466
+                .position(|candidate| candidate.identifier.as_ref() == app_id)
1467
+        })
1468
+        .unwrap_or(0)
1469
+}
1470
+
1471
+fn selected_adi_backend_index(
1472
+    backends: &[AdiBackendOption],
1473
+    backend: Option<StoredAdiBackendKind>,
1474
+) -> usize {
1475
+    backend
1476
+        .and_then(|backend| {
1477
+            let backend = AdiBackendKind::from(backend);
1478
+            backends
1479
+                .iter()
1480
+                .position(|candidate| candidate.kind == backend)
1481
+        })
1482
+        .unwrap_or_else(|| adi_services::default_backend(backends))
1483
+}
1484
+
1485
+fn ensure_android_adi_identifier(identifier: &mut String) -> bool {
1486
+    if identifier.len() == 16 {
1487
+        return false;
1488
+    }
1489
+
1490
+    *identifier = random_android_adi_identifier();
1491
+    true
1492
+}
1493
+
1494
+fn ensure_android_device_uuid(identifier: &mut String) -> bool {
1495
+    if is_uuid(identifier) {
1496
+        return false;
1497
+    }
1498
+
1499
+    *identifier = uuid::Uuid::new_v4().hyphenated().to_string().to_uppercase();
1500
+    true
1501
+}
1502
+
1503
+fn is_uuid(identifier: &str) -> bool {
1504
+    uuid::Uuid::parse_str(identifier).is_ok()
1505
+}
1506
+
1507
+fn android_device_identity_from_host(
1508
+    host_identity: &MachineIdentity,
1509
+    device_uuid: String,
1510
+) -> MachineIdentity {
1511
+    MachineIdentity {
1512
+        machine_name: host_identity.machine_name.clone(),
1513
+        os_name: host_identity.os_name.clone(),
1514
+        os_version: host_identity.os_version.clone(),
1515
+        machine_id: device_uuid.into(),
1516
+    }
1517
+}
1518
+
1519
+fn apply_machine_identity_preferences(
1520
+    identity: &mut MachineIdentity,
1521
+    preferences: &MachineIdentityPreferences,
1522
+) {
1523
+    if let Some(machine_name) = preferences.machine_name.as_ref() {
1524
+        identity.machine_name = machine_name.clone().into();
1525
+    }
1526
+    if let Some(os_name) = preferences.os_name.as_ref() {
1527
+        identity.os_name = os_name.clone().into();
1528
+    }
1529
+    if let Some(os_version) = preferences.os_version.as_ref() {
1530
+        identity.os_version = os_version.clone().into();
1531
+    }
1532
+    if let Some(machine_id) = preferences.machine_id.as_ref() {
1533
+        identity.machine_id = machine_id.clone().into();
1534
+    }
1535
+}
1536
+
1537
+fn random_android_adi_identifier() -> String {
1538
+    const HEX: &[u8; 16] = b"0123456789abcdef";
1539
+    let mut rng = rand::rng();
1540
+    let mut id = String::with_capacity(16);
1541
+    for _ in 0..16 {
1542
+        let index: usize = rng.random_range(0..HEX.len());
1543
+        id.push(HEX[index] as char);
1544
+    }
1545
+    id
1546
+}
1547
+
1548
+fn first_ipa_path(paths: &ExternalPaths) -> Option<PathBuf> {
1549
+    paths.paths().iter().find(|path| is_ipa_path(path)).cloned()
1550
+}
1551
+
1552
+fn paths_include_ipa(paths: &ExternalPaths) -> bool {
1553
+    first_ipa_path(paths).is_some()
1554
+}
1555
+
829 1556
 impl Render for SideloaderView {
830 1557
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
831
-        if self.sideload_operation.is_busy() {
1558
+        if self.sideload_operation.is_busy() || self.device_selection.is_refreshing() {
832 1559
             self.spinner_turns = (self.spinner_turns + 0.035) % 1.;
833 1560
             window.request_animation_frame();
834 1561
         } else {
... ...
@@ -843,6 +1570,12 @@ impl Render for SideloaderView {
843 1570
             .bg(rgb(0xf4f6f4))
844 1571
             .text_color(rgb(0x263238))
845 1572
             .font_family(".SystemUIFont")
1573
+            .can_drop(|dragged, _, _| {
1574
+                dragged
1575
+                    .downcast_ref::<ExternalPaths>()
1576
+                    .is_some_and(paths_include_ipa)
1577
+            })
1578
+            .on_drop(cx.listener(Self::drop_ipa))
846 1579
             .child(
847 1580
                 div()
848 1581
                     .flex()
... ...
@@ -3,7 +3,6 @@ use gpui::SharedString;
3 3
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
4 4
 pub(crate) enum PickerId {
5 5
     Account,
6
-    App,
7 6
     Device,
8 7
 }
9 8
 
... ...
@@ -24,6 +23,7 @@ pub(crate) struct TeamOption {
24 23
 
25 24
 #[derive(Clone, Debug)]
26 25
 pub(crate) struct AccountOption {
26
+    pub(crate) id: SharedString,
27 27
     pub(crate) label: SharedString,
28 28
     pub(crate) apple_id: SharedString,
29 29
     pub(crate) detail: SharedString,
... ...
@@ -37,16 +37,532 @@ pub(crate) struct PatchOption {
37 37
     pub(crate) detail: SharedString,
38 38
 }
39 39
 
40
+#[derive(Clone, Debug)]
41
+pub(crate) struct AppFieldValue {
42
+    pub(crate) default: SharedString,
43
+    pub(crate) override_value: Option<SharedString>,
44
+}
45
+
46
+impl AppFieldValue {
47
+    pub(crate) fn new(default: impl Into<SharedString>) -> Self {
48
+        Self {
49
+            default: default.into(),
50
+            override_value: None,
51
+        }
52
+    }
53
+
54
+    pub(crate) fn value(&self) -> &SharedString {
55
+        self.override_value.as_ref().unwrap_or(&self.default)
56
+    }
57
+
58
+    pub(crate) fn is_overridden(&self) -> bool {
59
+        self.override_value.is_some()
60
+    }
61
+
62
+    pub(crate) fn set_override(&mut self, value: String) {
63
+        if value == self.default.as_ref() {
64
+            self.override_value = None;
65
+        } else {
66
+            self.override_value = Some(value.into());
67
+        }
68
+    }
69
+
70
+    pub(crate) fn clear_override(&mut self) {
71
+        self.override_value = None;
72
+    }
73
+}
74
+
75
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
76
+pub(crate) enum SupportedDeviceFamily {
77
+    IPhone,
78
+    IPad,
79
+    AppleTv,
80
+    AppleWatch,
81
+    Mac,
82
+}
83
+
84
+impl SupportedDeviceFamily {
85
+    pub(crate) const ALL: [Self; 5] = [
86
+        Self::IPhone,
87
+        Self::IPad,
88
+        Self::AppleTv,
89
+        Self::AppleWatch,
90
+        Self::Mac,
91
+    ];
92
+
93
+    pub(crate) fn label(self) -> &'static str {
94
+        match self {
95
+            Self::IPhone => "iPhone",
96
+            Self::IPad => "iPad",
97
+            Self::AppleTv => "Apple TV",
98
+            Self::AppleWatch => "Apple Watch",
99
+            Self::Mac => "Mac",
100
+        }
101
+    }
102
+
103
+    pub(crate) fn from_device_family_id(id: u64) -> Option<Self> {
104
+        match id {
105
+            1 => Some(Self::IPhone),
106
+            2 => Some(Self::IPad),
107
+            3 => Some(Self::AppleTv),
108
+            4 => Some(Self::AppleWatch),
109
+            6 => Some(Self::Mac),
110
+            _ => None,
111
+        }
112
+    }
113
+
114
+    pub(crate) fn parse_list(value: &str) -> Vec<Self> {
115
+        let mut families = value
116
+            .split(',')
117
+            .filter_map(|value| Self::from_label(value.trim()))
118
+            .collect::<Vec<_>>();
119
+        normalize_supported_families(&mut families);
120
+        families
121
+    }
122
+
123
+    pub(crate) fn display_list(families: &[Self]) -> SharedString {
124
+        if families.is_empty() {
125
+            return "Unknown".into();
126
+        }
127
+
128
+        families
129
+            .iter()
130
+            .map(|family| family.label())
131
+            .collect::<Vec<_>>()
132
+            .join(", ")
133
+            .into()
134
+    }
135
+
136
+    fn from_label(value: &str) -> Option<Self> {
137
+        match value.to_ascii_lowercase().as_str() {
138
+            "iphone" | "ipod" | "ios" => Some(Self::IPhone),
139
+            "ipad" | "ipados" => Some(Self::IPad),
140
+            "apple tv" | "appletv" | "tvos" => Some(Self::AppleTv),
141
+            "apple watch" | "applewatch" | "watch" | "watchos" => Some(Self::AppleWatch),
142
+            "mac" | "macos" => Some(Self::Mac),
143
+            _ => None,
144
+        }
145
+    }
146
+}
147
+
148
+#[derive(Clone, Debug)]
149
+pub(crate) struct SupportedDevicesValue {
150
+    pub(crate) default: Vec<SupportedDeviceFamily>,
151
+    pub(crate) override_value: Option<Vec<SupportedDeviceFamily>>,
152
+}
153
+
154
+impl SupportedDevicesValue {
155
+    pub(crate) fn new(default: Vec<SupportedDeviceFamily>) -> Self {
156
+        Self {
157
+            default,
158
+            override_value: None,
159
+        }
160
+    }
161
+
162
+    pub(crate) fn value(&self) -> &[SupportedDeviceFamily] {
163
+        self.override_value.as_deref().unwrap_or(&self.default)
164
+    }
165
+
166
+    pub(crate) fn display_value(&self) -> SharedString {
167
+        SupportedDeviceFamily::display_list(self.value())
168
+    }
169
+
170
+    pub(crate) fn is_overridden(&self) -> bool {
171
+        self.override_value.is_some()
172
+    }
173
+
174
+    pub(crate) fn set_override(&mut self, mut value: Vec<SupportedDeviceFamily>) {
175
+        normalize_supported_families(&mut value);
176
+        if value == self.default {
177
+            self.override_value = None;
178
+        } else {
179
+            self.override_value = Some(value);
180
+        }
181
+    }
182
+
183
+    pub(crate) fn clear_override(&mut self) {
184
+        self.override_value = None;
185
+    }
186
+}
187
+
188
+fn normalize_supported_families(families: &mut Vec<SupportedDeviceFamily>) {
189
+    families.sort();
190
+    families.dedup();
191
+}
192
+
193
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194
+pub(crate) enum AppMetadataField {
195
+    Name,
196
+    BundleId,
197
+    Version,
198
+    Build,
199
+    Executable,
200
+    MinimumOs,
201
+    SupportedDevices,
202
+}
203
+
204
+impl AppMetadataField {
205
+    pub(crate) fn label(self) -> &'static str {
206
+        match self {
207
+            Self::Name => "Name",
208
+            Self::BundleId => "Bundle ID",
209
+            Self::Version => "Version",
210
+            Self::Build => "Build",
211
+            Self::Executable => "Executable",
212
+            Self::MinimumOs => "Minimum OS",
213
+            Self::SupportedDevices => "Supported devices",
214
+        }
215
+    }
216
+}
217
+
218
+#[derive(Clone, Debug)]
219
+pub(crate) struct AppMetadata {
220
+    pub(crate) name: AppFieldValue,
221
+    pub(crate) bundle_id: AppFieldValue,
222
+    pub(crate) version: AppFieldValue,
223
+    pub(crate) build: AppFieldValue,
224
+    pub(crate) executable: AppFieldValue,
225
+    pub(crate) minimum_os: AppFieldValue,
226
+    pub(crate) supported_devices: SupportedDevicesValue,
227
+}
228
+
229
+impl AppMetadata {
230
+    pub(crate) fn sample(
231
+        name: impl Into<SharedString>,
232
+        bundle_id: impl Into<SharedString>,
233
+        version: impl Into<SharedString>,
234
+        build: impl Into<SharedString>,
235
+        executable: impl Into<SharedString>,
236
+        minimum_os: impl Into<SharedString>,
237
+        supported_devices: Vec<SupportedDeviceFamily>,
238
+    ) -> Self {
239
+        Self {
240
+            name: AppFieldValue::new(name),
241
+            bundle_id: AppFieldValue::new(bundle_id),
242
+            version: AppFieldValue::new(version),
243
+            build: AppFieldValue::new(build),
244
+            executable: AppFieldValue::new(executable),
245
+            minimum_os: AppFieldValue::new(minimum_os),
246
+            supported_devices: SupportedDevicesValue::new(supported_devices),
247
+        }
248
+    }
249
+
250
+    pub(crate) fn field(&self, field: AppMetadataField) -> &AppFieldValue {
251
+        match field {
252
+            AppMetadataField::Name => &self.name,
253
+            AppMetadataField::BundleId => &self.bundle_id,
254
+            AppMetadataField::Version => &self.version,
255
+            AppMetadataField::Build => &self.build,
256
+            AppMetadataField::Executable => &self.executable,
257
+            AppMetadataField::MinimumOs => &self.minimum_os,
258
+            AppMetadataField::SupportedDevices => {
259
+                panic!("supported devices are not represented as a text metadata field")
260
+            }
261
+        }
262
+    }
263
+
264
+    pub(crate) fn field_mut(&mut self, field: AppMetadataField) -> &mut AppFieldValue {
265
+        match field {
266
+            AppMetadataField::Name => &mut self.name,
267
+            AppMetadataField::BundleId => &mut self.bundle_id,
268
+            AppMetadataField::Version => &mut self.version,
269
+            AppMetadataField::Build => &mut self.build,
270
+            AppMetadataField::Executable => &mut self.executable,
271
+            AppMetadataField::MinimumOs => &mut self.minimum_os,
272
+            AppMetadataField::SupportedDevices => {
273
+                panic!("supported devices are not represented as a text metadata field")
274
+            }
275
+        }
276
+    }
277
+}
278
+
279
+#[derive(Clone, Debug, PartialEq)]
280
+pub(crate) struct AppEntitlement {
281
+    pub(crate) key: SharedString,
282
+    pub(crate) value: EntitlementValue,
283
+}
284
+
285
+#[derive(Clone, Debug, PartialEq)]
286
+pub(crate) enum EntitlementValue {
287
+    String(SharedString),
288
+    Boolean(bool),
289
+    Integer(i64),
290
+    Number(f64),
291
+    Array(Vec<EntitlementValue>),
292
+    Dictionary(Vec<(SharedString, EntitlementValue)>),
293
+    Data(Vec<u8>),
294
+    Date(SharedString),
295
+    Uid(u64),
296
+    Unknown(SharedString),
297
+}
298
+
299
+impl EntitlementValue {
300
+    pub(crate) const EDITABLE_TYPE_LABELS: [&'static str; 3] = ["String", "Boolean", "Array"];
301
+
302
+    pub(crate) fn type_label(&self) -> &'static str {
303
+        match self {
304
+            Self::String(_) => "String",
305
+            Self::Boolean(_) => "Boolean",
306
+            Self::Integer(_) => "Integer",
307
+            Self::Number(_) => "Number",
308
+            Self::Array(_) => "Array",
309
+            Self::Dictionary(_) => "Dictionary",
310
+            Self::Data(_) => "Data",
311
+            Self::Date(_) => "Date",
312
+            Self::Uid(_) => "UID",
313
+            Self::Unknown(_) => "Value",
314
+        }
315
+    }
316
+
317
+    pub(crate) fn display_text(&self) -> SharedString {
318
+        self.edit_text().into()
319
+    }
320
+
321
+    pub(crate) fn edit_text(&self) -> String {
322
+        match self {
323
+            Self::String(value) => value.to_string(),
324
+            Self::Boolean(value) => value.to_string(),
325
+            Self::Integer(value) => value.to_string(),
326
+            Self::Number(value) => value.to_string(),
327
+            Self::Array(values) => values
328
+                .iter()
329
+                .map(Self::compact_text)
330
+                .collect::<Vec<_>>()
331
+                .join(", "),
332
+            Self::Dictionary(values) => format!("{} keys", values.len()),
333
+            Self::Data(value) => format!("{} bytes", value.len()),
334
+            Self::Date(value) => value.to_string(),
335
+            Self::Uid(value) => value.to_string(),
336
+            Self::Unknown(value) => value.to_string(),
337
+        }
338
+    }
339
+
340
+    pub(crate) fn with_type_label(&self, label: &str) -> Self {
341
+        Self::from_type_and_text(label, &self.edit_text())
342
+    }
343
+
344
+    pub(crate) fn with_edit_text(&self, text: &str) -> Self {
345
+        Self::from_type_and_text(self.type_label(), text)
346
+    }
347
+
348
+    pub(crate) fn string_array(values: Vec<String>) -> Self {
349
+        Self::Array(
350
+            values
351
+                .into_iter()
352
+                .map(|value| Self::String(value.into()))
353
+                .collect(),
354
+        )
355
+    }
356
+
357
+    pub(crate) fn array_edit_values(&self) -> Vec<String> {
358
+        match self {
359
+            Self::Array(values) => values.iter().map(Self::edit_text).collect(),
360
+            _ => vec![self.edit_text()],
361
+        }
362
+    }
363
+
364
+    pub(crate) fn from_type_and_text(label: &str, text: &str) -> Self {
365
+        match label {
366
+            "String" => Self::String(text.to_string().into()),
367
+            "Boolean" => Self::Boolean(matches!(
368
+                text.trim().to_ascii_lowercase().as_str(),
369
+                "true" | "yes" | "1"
370
+            )),
371
+            "Integer" => Self::Integer(text.trim().parse().unwrap_or_default()),
372
+            "Number" => Self::Number(text.trim().parse().unwrap_or_default()),
373
+            "Array" => Self::Array(
374
+                text.split(',')
375
+                    .map(str::trim)
376
+                    .filter(|value| !value.is_empty())
377
+                    .map(|value| Self::String(value.to_string().into()))
378
+                    .collect(),
379
+            ),
380
+            "Dictionary" => Self::Dictionary(Vec::new()),
381
+            "Data" => Self::Data(Vec::new()),
382
+            "Date" => Self::Date(text.to_string().into()),
383
+            "UID" => Self::Uid(text.trim().parse().unwrap_or_default()),
384
+            _ => Self::Unknown(text.to_string().into()),
385
+        }
386
+    }
387
+
388
+    fn compact_text(&self) -> String {
389
+        match self {
390
+            Self::Array(values) => format!("{} items", values.len()),
391
+            Self::Dictionary(values) => format!("{} keys", values.len()),
392
+            Self::Data(value) => format!("{} bytes", value.len()),
393
+            _ => self.edit_text(),
394
+        }
395
+    }
396
+}
397
+
40 398
 #[derive(Clone, Debug)]
41 399
 pub(crate) struct AppOption {
42
-    pub(crate) name: SharedString,
43
-    pub(crate) bundle_id: SharedString,
44
-    pub(crate) version: SharedString,
45
-    pub(crate) build: SharedString,
400
+    pub(crate) metadata: AppMetadata,
46 401
     pub(crate) path: SharedString,
402
+    pub(crate) icon_path: Option<SharedString>,
403
+    pub(crate) icon_override_path: Option<SharedString>,
404
+    pub(crate) entitlements: Vec<AppEntitlement>,
405
+    pub(crate) entitlement_overrides: Option<Vec<AppEntitlement>>,
47 406
     pub(crate) patches: Vec<PatchOption>,
48 407
 }
49 408
 
409
+impl AppOption {
410
+    pub(crate) fn name(&self) -> &SharedString {
411
+        self.metadata.name.value()
412
+    }
413
+
414
+    pub(crate) fn bundle_id(&self) -> &SharedString {
415
+        self.metadata.bundle_id.value()
416
+    }
417
+
418
+    pub(crate) fn version(&self) -> &SharedString {
419
+        self.metadata.version.value()
420
+    }
421
+
422
+    pub(crate) fn build(&self) -> &SharedString {
423
+        self.metadata.build.value()
424
+    }
425
+
426
+    pub(crate) fn field(&self, field: AppMetadataField) -> &AppFieldValue {
427
+        self.metadata.field(field)
428
+    }
429
+
430
+    pub(crate) fn field_display_value(&self, field: AppMetadataField) -> SharedString {
431
+        match field {
432
+            AppMetadataField::SupportedDevices => self.metadata.supported_devices.display_value(),
433
+            field => self.field(field).value().clone(),
434
+        }
435
+    }
436
+
437
+    pub(crate) fn field_is_overridden(&self, field: AppMetadataField) -> bool {
438
+        match field {
439
+            AppMetadataField::SupportedDevices => self.metadata.supported_devices.is_overridden(),
440
+            field => self.field(field).is_overridden(),
441
+        }
442
+    }
443
+
444
+    pub(crate) fn set_field_override(&mut self, field: AppMetadataField, value: String) {
445
+        match field {
446
+            AppMetadataField::SupportedDevices => self
447
+                .metadata
448
+                .supported_devices
449
+                .set_override(SupportedDeviceFamily::parse_list(&value)),
450
+            field => self.metadata.field_mut(field).set_override(value),
451
+        }
452
+    }
453
+
454
+    pub(crate) fn set_supported_devices_override(&mut self, value: Vec<SupportedDeviceFamily>) {
455
+        self.metadata.supported_devices.set_override(value);
456
+    }
457
+
458
+    pub(crate) fn clear_field_override(&mut self, field: AppMetadataField) {
459
+        match field {
460
+            AppMetadataField::SupportedDevices => self.metadata.supported_devices.clear_override(),
461
+            field => self.metadata.field_mut(field).clear_override(),
462
+        }
463
+    }
464
+
465
+    pub(crate) fn supported_devices(&self) -> &[SupportedDeviceFamily] {
466
+        self.metadata.supported_devices.value()
467
+    }
468
+
469
+    pub(crate) fn displayed_icon_path(&self) -> Option<&SharedString> {
470
+        self.icon_override_path.as_ref().or(self.icon_path.as_ref())
471
+    }
472
+
473
+    pub(crate) fn effective_entitlements(&self, team_id: &SharedString) -> Vec<AppEntitlement> {
474
+        if let Some(overrides) = self.entitlement_overrides.as_ref() {
475
+            return overrides.clone();
476
+        }
477
+
478
+        self.default_effective_entitlements(team_id)
479
+    }
480
+
481
+    pub(crate) fn default_effective_entitlements(
482
+        &self,
483
+        team_id: &SharedString,
484
+    ) -> Vec<AppEntitlement> {
485
+        self.entitlements
486
+            .iter()
487
+            .map(|entitlement| team_adjusted_entitlement(entitlement, self.bundle_id(), team_id))
488
+            .collect()
489
+    }
490
+
491
+    pub(crate) fn entitlements_are_overridden(&self) -> bool {
492
+        self.entitlement_overrides.is_some()
493
+    }
494
+}
495
+
496
+fn team_adjusted_entitlement(
497
+    entitlement: &AppEntitlement,
498
+    bundle_id: &SharedString,
499
+    team_id: &SharedString,
500
+) -> AppEntitlement {
501
+    let mut entitlement = entitlement.clone();
502
+    match entitlement.key.as_ref() {
503
+        "application-identifier" => {
504
+            entitlement.value = EntitlementValue::String(team_prefixed_identifier(
505
+                &entitlement.value.edit_text(),
506
+                bundle_id,
507
+                team_id,
508
+            ));
509
+        }
510
+        "com.apple.developer.team-identifier" => {
511
+            entitlement.value = EntitlementValue::String(team_id.clone());
512
+        }
513
+        "keychain-access-groups" => {
514
+            entitlement.value =
515
+                team_prefixed_keychain_groups(&entitlement.value, bundle_id, team_id);
516
+        }
517
+        _ => {}
518
+    }
519
+    entitlement
520
+}
521
+
522
+fn team_prefixed_keychain_groups(
523
+    value: &EntitlementValue,
524
+    bundle_id: &SharedString,
525
+    team_id: &SharedString,
526
+) -> EntitlementValue {
527
+    match value {
528
+        EntitlementValue::Array(values) => EntitlementValue::Array(
529
+            values
530
+                .iter()
531
+                .map(|value| match value {
532
+                    EntitlementValue::String(value) => EntitlementValue::String(
533
+                        team_prefixed_identifier(value.as_ref(), bundle_id, team_id),
534
+                    ),
535
+                    value => value.clone(),
536
+                })
537
+                .collect(),
538
+        ),
539
+        value => EntitlementValue::Array(
540
+            value
541
+                .edit_text()
542
+                .split(',')
543
+                .map(str::trim)
544
+                .filter(|value| !value.is_empty())
545
+                .map(|value| {
546
+                    EntitlementValue::String(team_prefixed_identifier(value, bundle_id, team_id))
547
+                })
548
+                .collect(),
549
+        ),
550
+    }
551
+}
552
+
553
+fn team_prefixed_identifier(
554
+    value: &str,
555
+    bundle_id: &SharedString,
556
+    team_id: &SharedString,
557
+) -> SharedString {
558
+    let suffix = value
559
+        .split_once('.')
560
+        .map(|(_, suffix)| suffix)
561
+        .filter(|suffix| !suffix.is_empty())
562
+        .unwrap_or(bundle_id.as_ref());
563
+    format!("{team_id}.{suffix}").into()
564
+}
565
+
50 566
 #[derive(Clone, Debug)]
51 567
 pub(crate) struct DeviceOption {
52 568
     pub(crate) name: SharedString,
... ...
@@ -56,14 +572,100 @@ pub(crate) struct DeviceOption {
56 572
     pub(crate) connection: SharedString,
57 573
 }
58 574
 
575
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
576
+pub(crate) enum AdiBackendKind {
577
+    SystemAdid,
578
+    WindowsCoreAdi,
579
+    AndroidCoreAdi,
580
+}
581
+
582
+#[derive(Clone, Debug)]
583
+pub(crate) struct AdiBackendDetail {
584
+    pub(crate) label: String,
585
+    pub(crate) value: String,
586
+}
587
+
588
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
589
+pub(crate) enum AdiBackendAvailability {
590
+    Ready,
591
+    NeedsSetup,
592
+    Unavailable,
593
+}
594
+
595
+impl AdiBackendAvailability {
596
+    pub(crate) fn label(self) -> String {
597
+        match self {
598
+            Self::Ready => "Ready",
599
+            Self::NeedsSetup => "Needs setup",
600
+            Self::Unavailable => "Unavailable",
601
+        }
602
+        .to_string()
603
+    }
604
+
605
+    pub(crate) fn is_ready(self) -> bool {
606
+        self == Self::Ready
607
+    }
608
+
609
+    pub(crate) fn is_available(self) -> bool {
610
+        self != Self::Unavailable
611
+    }
612
+}
613
+
614
+#[derive(Clone, Debug)]
615
+pub(crate) enum AdiProvisioningState {
616
+    Unknown,
617
+    NotAvailable,
618
+    Provisioned,
619
+    NotProvisioned,
620
+    Error(String),
621
+}
622
+
623
+impl AdiProvisioningState {
624
+    pub(crate) fn label(&self) -> String {
625
+        match self {
626
+            Self::Unknown => "Unknown",
627
+            Self::NotAvailable => "Not available",
628
+            Self::Provisioned => "Provisioned",
629
+            Self::NotProvisioned => "Not provisioned",
630
+            Self::Error(_) => "Failed",
631
+        }
632
+        .to_string()
633
+    }
634
+
635
+    pub(crate) fn detail(&self) -> Option<String> {
636
+        match self {
637
+            Self::Error(error) => Some(error.clone()),
638
+            _ => None,
639
+        }
640
+    }
641
+}
642
+
643
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
644
+pub(crate) enum AdiRepairAction {
645
+    InstallCoreAdi,
646
+    LocateLibrary,
647
+}
648
+
649
+impl AdiRepairAction {
650
+    pub(crate) fn label(self) -> String {
651
+        match self {
652
+            Self::InstallCoreAdi => "Install CoreADI",
653
+            Self::LocateLibrary => "Locate Library",
654
+        }
655
+        .to_string()
656
+    }
657
+}
658
+
59 659
 #[derive(Clone, Debug)]
60 660
 pub(crate) struct AdiBackendOption {
61
-    pub(crate) name: SharedString,
62
-    pub(crate) detail: SharedString,
63
-    pub(crate) status: SharedString,
64
-    pub(crate) information: SharedString,
661
+    pub(crate) kind: AdiBackendKind,
662
+    pub(crate) name: String,
663
+    pub(crate) detail: String,
664
+    pub(crate) availability: AdiBackendAvailability,
665
+    pub(crate) details: Vec<AdiBackendDetail>,
666
+    pub(crate) provisioning_state: AdiProvisioningState,
65 667
     pub(crate) editable_identity: bool,
66
-    pub(crate) repair_action: Option<SharedString>,
668
+    pub(crate) repair_action: Option<AdiRepairAction>,
67 669
 }
68 670
 
69 671
 #[derive(Clone, Debug)]
... ...
@@ -129,14 +731,39 @@ impl SideloadOperation {
129 731
         }
130 732
     }
131 733
 
132
-    pub(crate) fn status_text(&self, app: &AppOption, device: &DeviceOption) -> SharedString {
734
+    pub(crate) fn status_text(
735
+        &self,
736
+        app: Option<&AppOption>,
737
+        device: Option<&DeviceOption>,
738
+    ) -> SharedString {
133 739
         match self {
134
-            Self::Idle => "Ready to install".into(),
740
+            Self::Idle => {
741
+                if app.is_none() {
742
+                    "Choose an IPA".into()
743
+                } else if device.is_some() {
744
+                    "Ready to install".into()
745
+                } else {
746
+                    "Connect a device".into()
747
+                }
748
+            }
135 749
             Self::Running { phase, .. } => match phase {
136
-                SideloadPhase::Signing => format!("Signing {}", app.name).into(),
137
-                SideloadPhase::Installing => format!("Installing to {}", device.name).into(),
750
+                SideloadPhase::Signing => {
751
+                    let app_name = app.map(|app| app.name().as_ref()).unwrap_or("app");
752
+                    format!("Signing {app_name}").into()
753
+                }
754
+                SideloadPhase::Installing => {
755
+                    let device_name = device
756
+                        .map(|device| device.name.as_ref())
757
+                        .unwrap_or("device");
758
+                    format!("Installing to {device_name}").into()
759
+                }
138 760
             },
139
-            Self::Finished => format!("Installed on {}", device.name).into(),
761
+            Self::Finished => {
762
+                let device_name = device
763
+                    .map(|device| device.name.as_ref())
764
+                    .unwrap_or("device");
765
+                format!("Installed on {device_name}").into()
766
+            }
140 767
             Self::Failed { message } => message.clone(),
141 768
         }
142 769
     }
... ...
@@ -0,0 +1,14 @@
1
+use directories::ProjectDirs;
2
+use std::path::PathBuf;
3
+
4
+const QUALIFIER: &str = "com";
5
+const ORGANIZATION: &str = "Dadoum";
6
+const APPLICATION: &str = "Super Sideloader";
7
+
8
+pub(crate) fn app_data_dir() -> Option<PathBuf> {
9
+    project_dirs().map(|dirs| dirs.data_dir().to_path_buf())
10
+}
11
+
12
+fn project_dirs() -> Option<ProjectDirs> {
13
+    ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
14
+}
... ...
@@ -0,0 +1,291 @@
1
+use crate::models::{
2
+    AdiBackendKind, AppEntitlement, AppOption, EntitlementValue, MachineIdentity,
3
+    SupportedDeviceFamily,
4
+};
5
+use crate::paths::app_data_dir;
6
+use serde::{Deserialize, Serialize};
7
+use std::fs;
8
+use std::io::ErrorKind;
9
+use std::path::PathBuf;
10
+
11
+const SETTINGS_FILE: &str = "settings.toml";
12
+
13
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
14
+#[serde(default)]
15
+pub(crate) struct SideloaderPreferences {
16
+    pub(crate) developer: DeveloperPreferences,
17
+    pub(crate) app: AppPreferences,
18
+    pub(crate) device: Option<DevicePreferences>,
19
+    pub(crate) adi: AdiPreferences,
20
+}
21
+
22
+#[derive(Clone, Debug, Deserialize, Serialize)]
23
+#[serde(default)]
24
+pub(crate) struct DeveloperPreferences {
25
+    pub(crate) account_id: Option<String>,
26
+    pub(crate) team_id: Option<String>,
27
+    pub(crate) auto_app_id: bool,
28
+    pub(crate) app_id: Option<String>,
29
+}
30
+
31
+impl Default for DeveloperPreferences {
32
+    fn default() -> Self {
33
+        Self {
34
+            account_id: None,
35
+            team_id: None,
36
+            auto_app_id: true,
37
+            app_id: None,
38
+        }
39
+    }
40
+}
41
+
42
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
43
+#[serde(default)]
44
+pub(crate) struct AppPreferences {
45
+    pub(crate) bundle_id: Option<String>,
46
+    pub(crate) path: Option<String>,
47
+    pub(crate) overrides: AppOverridePreferences,
48
+}
49
+
50
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
51
+#[serde(default)]
52
+pub(crate) struct AppOverridePreferences {
53
+    pub(crate) name: Option<String>,
54
+    pub(crate) bundle_id: Option<String>,
55
+    pub(crate) version: Option<String>,
56
+    pub(crate) build: Option<String>,
57
+    pub(crate) executable: Option<String>,
58
+    pub(crate) minimum_os: Option<String>,
59
+    pub(crate) supported_devices: Option<String>,
60
+    pub(crate) icon_path: Option<String>,
61
+    pub(crate) entitlements: Option<Vec<EntitlementPreferences>>,
62
+}
63
+
64
+#[derive(Clone, Debug, Deserialize, Serialize)]
65
+pub(crate) struct EntitlementPreferences {
66
+    pub(crate) key: String,
67
+    pub(crate) value_type: String,
68
+    pub(crate) value: String,
69
+    #[serde(default, skip_serializing_if = "Option::is_none")]
70
+    pub(crate) values: Option<Vec<String>>,
71
+}
72
+
73
+#[derive(Clone, Debug, Deserialize, Serialize)]
74
+pub(crate) struct DevicePreferences {
75
+    pub(crate) udid: String,
76
+    pub(crate) connection: String,
77
+}
78
+
79
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
80
+#[serde(default)]
81
+pub(crate) struct AdiPreferences {
82
+    pub(crate) backend: Option<StoredAdiBackendKind>,
83
+    pub(crate) machine: MachineIdentityPreferences,
84
+    pub(crate) android_adi_identifier: Option<String>,
85
+    pub(crate) android_device: MachineIdentityPreferences,
86
+    pub(crate) android_device_uuid: Option<String>,
87
+    #[serde(skip_serializing)]
88
+    pub(crate) android_machine: MachineIdentityPreferences,
89
+}
90
+
91
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
92
+#[serde(default)]
93
+pub(crate) struct MachineIdentityPreferences {
94
+    pub(crate) machine_name: Option<String>,
95
+    pub(crate) os_name: Option<String>,
96
+    pub(crate) os_version: Option<String>,
97
+    pub(crate) machine_id: Option<String>,
98
+}
99
+
100
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
101
+#[serde(rename_all = "snake_case")]
102
+pub(crate) enum StoredAdiBackendKind {
103
+    SystemAdid,
104
+    WindowsCoreAdi,
105
+    AndroidCoreAdi,
106
+}
107
+
108
+impl From<AdiBackendKind> for StoredAdiBackendKind {
109
+    fn from(kind: AdiBackendKind) -> Self {
110
+        match kind {
111
+            AdiBackendKind::SystemAdid => Self::SystemAdid,
112
+            AdiBackendKind::WindowsCoreAdi => Self::WindowsCoreAdi,
113
+            AdiBackendKind::AndroidCoreAdi => Self::AndroidCoreAdi,
114
+        }
115
+    }
116
+}
117
+
118
+impl From<StoredAdiBackendKind> for AdiBackendKind {
119
+    fn from(kind: StoredAdiBackendKind) -> Self {
120
+        match kind {
121
+            StoredAdiBackendKind::SystemAdid => Self::SystemAdid,
122
+            StoredAdiBackendKind::WindowsCoreAdi => Self::WindowsCoreAdi,
123
+            StoredAdiBackendKind::AndroidCoreAdi => Self::AndroidCoreAdi,
124
+        }
125
+    }
126
+}
127
+
128
+impl From<&MachineIdentity> for MachineIdentityPreferences {
129
+    fn from(identity: &MachineIdentity) -> Self {
130
+        Self {
131
+            machine_name: Some(identity.machine_name.to_string()),
132
+            os_name: Some(identity.os_name.to_string()),
133
+            os_version: Some(identity.os_version.to_string()),
134
+            machine_id: Some(identity.machine_id.to_string()),
135
+        }
136
+    }
137
+}
138
+
139
+impl From<&AppEntitlement> for EntitlementPreferences {
140
+    fn from(entitlement: &AppEntitlement) -> Self {
141
+        Self {
142
+            key: entitlement.key.to_string(),
143
+            value_type: entitlement.value.type_label().to_string(),
144
+            value: entitlement.value.edit_text(),
145
+            values: matches!(entitlement.value, EntitlementValue::Array(_))
146
+                .then(|| entitlement.value.array_edit_values()),
147
+        }
148
+    }
149
+}
150
+
151
+impl From<&EntitlementPreferences> for AppEntitlement {
152
+    fn from(entitlement: &EntitlementPreferences) -> Self {
153
+        let value = if entitlement.value_type == "Array" {
154
+            entitlement
155
+                .values
156
+                .clone()
157
+                .map(EntitlementValue::string_array)
158
+                .unwrap_or_else(|| {
159
+                    EntitlementValue::from_type_and_text(
160
+                        &entitlement.value_type,
161
+                        &entitlement.value,
162
+                    )
163
+                })
164
+        } else {
165
+            EntitlementValue::from_type_and_text(&entitlement.value_type, &entitlement.value)
166
+        };
167
+
168
+        Self {
169
+            key: entitlement.key.clone().into(),
170
+            value,
171
+        }
172
+    }
173
+}
174
+
175
+impl From<&AppOption> for AppOverridePreferences {
176
+    fn from(app: &AppOption) -> Self {
177
+        Self {
178
+            name: app
179
+                .metadata
180
+                .name
181
+                .override_value
182
+                .as_ref()
183
+                .map(ToString::to_string),
184
+            bundle_id: app
185
+                .metadata
186
+                .bundle_id
187
+                .override_value
188
+                .as_ref()
189
+                .map(ToString::to_string),
190
+            version: app
191
+                .metadata
192
+                .version
193
+                .override_value
194
+                .as_ref()
195
+                .map(ToString::to_string),
196
+            build: app
197
+                .metadata
198
+                .build
199
+                .override_value
200
+                .as_ref()
201
+                .map(ToString::to_string),
202
+            executable: app
203
+                .metadata
204
+                .executable
205
+                .override_value
206
+                .as_ref()
207
+                .map(ToString::to_string),
208
+            minimum_os: app
209
+                .metadata
210
+                .minimum_os
211
+                .override_value
212
+                .as_ref()
213
+                .map(ToString::to_string),
214
+            supported_devices: app
215
+                .metadata
216
+                .supported_devices
217
+                .override_value
218
+                .as_ref()
219
+                .map(|devices| SupportedDeviceFamily::display_list(devices).to_string()),
220
+            icon_path: app.icon_override_path.as_ref().map(ToString::to_string),
221
+            entitlements: app.entitlement_overrides.as_ref().map(|entitlements| {
222
+                entitlements
223
+                    .iter()
224
+                    .map(EntitlementPreferences::from)
225
+                    .collect()
226
+            }),
227
+        }
228
+    }
229
+}
230
+
231
+pub(crate) fn apply_app_overrides(app: &mut AppOption, overrides: &AppOverridePreferences) {
232
+    app.metadata.name.override_value = overrides.name.clone().map(Into::into);
233
+    app.metadata.bundle_id.override_value = overrides.bundle_id.clone().map(Into::into);
234
+    app.metadata.version.override_value = overrides.version.clone().map(Into::into);
235
+    app.metadata.build.override_value = overrides.build.clone().map(Into::into);
236
+    app.metadata.executable.override_value = overrides.executable.clone().map(Into::into);
237
+    app.metadata.minimum_os.override_value = overrides.minimum_os.clone().map(Into::into);
238
+    app.metadata.supported_devices.override_value = overrides
239
+        .supported_devices
240
+        .as_ref()
241
+        .map(|devices| SupportedDeviceFamily::parse_list(devices));
242
+    app.icon_override_path = overrides.icon_path.clone().map(Into::into);
243
+    app.entitlement_overrides = overrides.entitlements.as_ref().map(|entitlements| {
244
+        entitlements
245
+            .iter()
246
+            .map(AppEntitlement::from)
247
+            .collect::<Vec<_>>()
248
+    });
249
+}
250
+
251
+pub(crate) fn load_preferences() -> Result<SideloaderPreferences, String> {
252
+    let path = settings_path()?;
253
+    let contents = match fs::read_to_string(&path) {
254
+        Ok(contents) => contents,
255
+        Err(error) if error.kind() == ErrorKind::NotFound => {
256
+            return Ok(SideloaderPreferences::default());
257
+        }
258
+        Err(error) => {
259
+            return Err(format!(
260
+                "Failed to read settings at {}: {error}",
261
+                path.display()
262
+            ));
263
+        }
264
+    };
265
+
266
+    toml::from_str(&contents)
267
+        .map_err(|error| format!("Failed to parse settings at {}: {error}", path.display()))
268
+}
269
+
270
+pub(crate) fn save_preferences(preferences: &SideloaderPreferences) -> Result<(), String> {
271
+    let path = settings_path()?;
272
+    if let Some(parent) = path.parent() {
273
+        fs::create_dir_all(parent).map_err(|error| {
274
+            format!(
275
+                "Failed to create settings folder at {}: {error}",
276
+                parent.display()
277
+            )
278
+        })?;
279
+    }
280
+
281
+    let contents = toml::to_string_pretty(preferences)
282
+        .map_err(|error| format!("Failed to encode settings: {error}"))?;
283
+    fs::write(&path, contents)
284
+        .map_err(|error| format!("Failed to write settings at {}: {error}", path.display()))
285
+}
286
+
287
+fn settings_path() -> Result<PathBuf, String> {
288
+    app_data_dir()
289
+        .map(|path| path.join(SETTINGS_FILE))
290
+        .ok_or_else(|| "The application data folder is not available.".to_string())
291
+}
... ...
@@ -1,19 +1,40 @@
1
+mod adi_view;
2
+mod app_view;
3
+mod developer_login_view;
4
+mod developer_view;
5
+use crate::adi_services::{self, CoreAdiInstallEvent};
1 6
 use crate::constants::*;
7
+use crate::developer_accounts::{
8
+    mock_developer_login, mock_developer_login_with_code, MockDeveloperLoginOutcome,
9
+};
10
+use crate::edit_line::EditLine;
2 11
 use crate::main_view::SideloaderView;
3
-use crate::models::{AdiBackendOption, AppIdOption, AppOption, MachineIdentity, TeamOption};
12
+use crate::models::{
13
+    AccountOption, AdiBackendKind, AdiBackendOption, AdiProvisioningState, AppEntitlement,
14
+    AppIdOption, AppMetadataField, AppOption, EntitlementValue, MachineIdentity,
15
+    SupportedDeviceFamily, TeamOption,
16
+};
17
+use crate::paths::app_data_dir;
4 18
 use crate::widgets::{
5 19
     chevron, combo_button, combo_item_content, combo_with_popover, developer_account_title,
6
-    dropdown_list, lucide_icon, properties_list,
20
+    dropdown_list, lucide_icon, progress_circle, properties_list,
7 21
 };
22
+use futures::{channel::mpsc, StreamExt};
8 23
 use gpui::{
9
-    anchored, deferred, div, point, prelude::*, px, rgb, size, AnchoredPositionMode, App,
10
-    AppContext, Bounds, ClickEvent, Context, FocusHandle, FontWeight, InteractiveElement,
11
-    IntoElement, ParentElement, Render, ScrollHandle, SharedString, StatefulInteractiveElement,
12
-    Styled, WeakEntity, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions,
24
+    anchored, deferred, div, img, point, prelude::*, px, rgb, size, AnchoredPositionMode, App,
25
+    AppContext, Bounds, ClickEvent, Context, Entity, FocusHandle, FontWeight, InteractiveElement,
26
+    IntoElement, KeyDownEvent, ObjectFit, ParentElement, PathPromptOptions, Render, ScrollHandle,
27
+    SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, WindowBounds,
28
+    WindowHandle, WindowKind, WindowOptions,
13 29
 };
30
+use std::sync::Arc;
31
+use std::{fs, process::Command};
14 32
 
15 33
 #[derive(Clone)]
16 34
 pub(crate) enum SettingsWindowRequest {
35
+    DeveloperLogin {
36
+        parent: WeakEntity<SideloaderView>,
37
+    },
17 38
     Team {
18 39
         parent: WeakEntity<SideloaderView>,
19 40
         teams: Vec<TeamOption>,
... ...
@@ -22,15 +43,19 @@ pub(crate) enum SettingsWindowRequest {
22 43
         selected_app_id: usize,
23 44
     },
24 45
     AppSettings {
25
-        _parent: WeakEntity<SideloaderView>,
46
+        parent: WeakEntity<SideloaderView>,
47
+        app_index: usize,
26 48
         app: AppOption,
27 49
         enabled_patches: Vec<bool>,
50
+        team_id: SharedString,
28 51
     },
29 52
     AdiSettings {
30 53
         parent: WeakEntity<SideloaderView>,
31 54
         backends: Vec<AdiBackendOption>,
32 55
         selected_backend: usize,
33 56
         machine_identity: MachineIdentity,
57
+        android_device_identity: MachineIdentity,
58
+        android_adi_identifier: String,
34 59
     },
35 60
 }
36 61
 
... ...
@@ -41,10 +66,222 @@ pub(crate) struct SettingsWindow {
41 66
     team_picker_open: bool,
42 67
     app_id_picker_open: bool,
43 68
     adi_backend_picker_open: bool,
69
+    adi_operation: Option<AdiOperationState>,
70
+    machine_identity_edit: Option<MachineIdentityEdit>,
71
+    app_detail_edit: Option<AppDetailEdit>,
72
+    selected_entitlement: Option<usize>,
73
+    entitlement_edit: Option<EntitlementEdit>,
74
+    entitlement_type_picker_open: bool,
75
+    developer_login: DeveloperLoginState,
76
+    spinner_turns: f32,
77
+}
78
+
79
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80
+enum DeveloperLoginStep {
81
+    Credentials,
82
+    TwoFactor,
83
+}
84
+
85
+struct DeveloperLoginState {
86
+    email: Entity<EditLine>,
87
+    password: Entity<EditLine>,
88
+    code: Entity<EditLine>,
89
+    remember_account: bool,
90
+    step: DeveloperLoginStep,
91
+    two_factor_detail: SharedString,
92
+    error: Option<SharedString>,
93
+}
94
+
95
+impl DeveloperLoginState {
96
+    fn new(cx: &mut Context<SettingsWindow>) -> Self {
97
+        Self {
98
+            email: cx.new(|cx| EditLine::new("", cx)),
99
+            password: cx.new(|cx| EditLine::new_password("", cx)),
100
+            code: cx.new(|cx| EditLine::new("", cx)),
101
+            remember_account: false,
102
+            step: DeveloperLoginStep::Credentials,
103
+            two_factor_detail: "".into(),
104
+            error: None,
105
+        }
106
+    }
107
+}
108
+
109
+#[derive(Clone, Debug)]
110
+enum AdiOperationState {
111
+    DownloadingCoreAdi {
112
+        downloaded_bytes: u64,
113
+        total_bytes: Option<u64>,
114
+    },
115
+    InstallingCoreAdi,
116
+    Provisioning,
117
+}
118
+
119
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
120
+enum MachineIdentityField {
121
+    MachineModel,
122
+    OsName,
123
+    OsVersion,
124
+    MachineId,
125
+}
126
+
127
+impl MachineIdentityField {
128
+    fn label(self) -> &'static str {
129
+        match self {
130
+            Self::MachineModel => "Machine model",
131
+            Self::OsName => "OS name",
132
+            Self::OsVersion => "OS version",
133
+            Self::MachineId => "Machine ID",
134
+        }
135
+    }
136
+
137
+    fn value(self, identity: &MachineIdentity) -> &SharedString {
138
+        match self {
139
+            Self::MachineModel => &identity.machine_name,
140
+            Self::OsName => &identity.os_name,
141
+            Self::OsVersion => &identity.os_version,
142
+            Self::MachineId => &identity.machine_id,
143
+        }
144
+    }
145
+
146
+    fn set_value(self, identity: &mut MachineIdentity, value: String) {
147
+        match self {
148
+            Self::MachineModel => identity.machine_name = value.into(),
149
+            Self::OsName => identity.os_name = value.into(),
150
+            Self::OsVersion => identity.os_version = value.into(),
151
+            Self::MachineId => identity.machine_id = value.into(),
152
+        }
153
+    }
154
+}
155
+
156
+#[derive(Clone)]
157
+struct MachineIdentityEdit {
158
+    field: MachineIdentityField,
159
+    input: Entity<EditLine>,
160
+}
161
+
162
+#[derive(Clone)]
163
+enum AppDetailEdit {
164
+    Text {
165
+        field: AppMetadataField,
166
+        input: Entity<EditLine>,
167
+    },
168
+    SupportedDevices {
169
+        selected: Vec<SupportedDeviceFamily>,
170
+    },
171
+}
172
+
173
+impl AppDetailEdit {
174
+    fn field(&self) -> AppMetadataField {
175
+        match self {
176
+            Self::Text { field, .. } => *field,
177
+            Self::SupportedDevices { .. } => AppMetadataField::SupportedDevices,
178
+        }
179
+    }
180
+}
181
+
182
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
183
+enum EntitlementField {
184
+    Key,
185
+    ValueType,
186
+    Value,
187
+}
188
+
189
+#[derive(Clone)]
190
+enum EntitlementEdit {
191
+    Text {
192
+        row: usize,
193
+        field: EntitlementField,
194
+        input: Entity<EditLine>,
195
+    },
196
+    Boolean {
197
+        row: usize,
198
+        value: bool,
199
+    },
200
+    Array {
201
+        row: usize,
202
+        items: Vec<Entity<EditLine>>,
203
+    },
204
+    Type {
205
+        row: usize,
206
+        selected: SharedString,
207
+    },
208
+}
209
+
210
+impl EntitlementEdit {
211
+    fn row(&self) -> usize {
212
+        match self {
213
+            Self::Text { row, .. }
214
+            | Self::Boolean { row, .. }
215
+            | Self::Array { row, .. }
216
+            | Self::Type { row, .. } => *row,
217
+        }
218
+    }
219
+
220
+    fn field(&self) -> EntitlementField {
221
+        match self {
222
+            Self::Text { field, .. } => *field,
223
+            Self::Boolean { .. } | Self::Array { .. } => EntitlementField::Value,
224
+            Self::Type { .. } => EntitlementField::ValueType,
225
+        }
226
+    }
227
+}
228
+
229
+impl AdiOperationState {
230
+    fn label(&self) -> SharedString {
231
+        match self {
232
+            Self::DownloadingCoreAdi {
233
+                downloaded_bytes,
234
+                total_bytes: Some(total_bytes),
235
+            } if *total_bytes > 0 => {
236
+                let percent = ((*downloaded_bytes as f32 / *total_bytes as f32) * 100.)
237
+                    .clamp(0., 99.)
238
+                    .round() as u32;
239
+                format!("Downloading {percent}%").into()
240
+            }
241
+            Self::DownloadingCoreAdi { .. } => "Downloading...".into(),
242
+            Self::InstallingCoreAdi => "Installing...".into(),
243
+            Self::Provisioning => "Provisioning...".into(),
244
+        }
245
+    }
246
+
247
+    fn progress(&self) -> f32 {
248
+        match self {
249
+            Self::DownloadingCoreAdi {
250
+                downloaded_bytes,
251
+                total_bytes: Some(total_bytes),
252
+            } if *total_bytes > 0 => (*downloaded_bytes as f32 / *total_bytes as f32).clamp(0., 1.),
253
+            Self::DownloadingCoreAdi { .. } => 0.28,
254
+            Self::InstallingCoreAdi => 0.72,
255
+            Self::Provisioning => 0.45,
256
+        }
257
+    }
258
+
259
+    fn is_indeterminate(&self) -> bool {
260
+        matches!(
261
+            self,
262
+            Self::DownloadingCoreAdi {
263
+                total_bytes: None,
264
+                ..
265
+            } | Self::InstallingCoreAdi
266
+                | Self::Provisioning
267
+        )
268
+    }
269
+
270
+    fn is_coreadi_install(&self) -> bool {
271
+        matches!(
272
+            self,
273
+            Self::DownloadingCoreAdi { .. } | Self::InstallingCoreAdi
274
+        )
275
+    }
276
+
277
+    fn is_provisioning(&self) -> bool {
278
+        matches!(self, Self::Provisioning)
279
+    }
44 280
 }
45 281
 impl SettingsWindowRequest {
46 282
     fn title(&self) -> &'static str {
47 283
         match self {
284
+            SettingsWindowRequest::DeveloperLogin { .. } => "Add Apple Account",
48 285
             SettingsWindowRequest::Team { .. } => "Developer Settings",
49 286
             SettingsWindowRequest::AppSettings { .. } => "App Settings",
50 287
             SettingsWindowRequest::AdiSettings { .. } => "Settings",
... ...
@@ -65,6 +302,14 @@ impl SettingsWindow {
65 302
             team_picker_open: false,
66 303
             app_id_picker_open: false,
67 304
             adi_backend_picker_open: false,
305
+            adi_operation: None,
306
+            machine_identity_edit: None,
307
+            app_detail_edit: None,
308
+            selected_entitlement: None,
309
+            entitlement_edit: None,
310
+            entitlement_type_picker_open: false,
311
+            developer_login: DeveloperLoginState::new(cx),
312
+            spinner_turns: 0.,
68 313
         }
69 314
     }
70 315
 
... ...
@@ -74,16 +319,189 @@ impl SettingsWindow {
74 319
         window: &mut Window,
75 320
         cx: &mut Context<Self>,
76 321
     ) {
322
+        let reset_login = matches!(&request, SettingsWindowRequest::DeveloperLogin { .. });
323
+        let title = request.title();
77 324
         self.request = request;
78 325
         self.team_picker_open = false;
79 326
         self.app_id_picker_open = false;
80 327
         self.adi_backend_picker_open = false;
328
+        self.machine_identity_edit = None;
329
+        self.app_detail_edit = None;
330
+        self.selected_entitlement = None;
331
+        self.entitlement_edit = None;
332
+        self.entitlement_type_picker_open = false;
333
+        if reset_login {
334
+            self.developer_login = DeveloperLoginState::new(cx);
335
+        }
336
+        window.set_window_title(title);
81 337
         window.activate_window();
82 338
         window.focus(&self.focus_handle, cx);
83 339
         cx.defer_in(window, |_, _, cx| cx.notify());
84 340
         cx.notify();
85 341
     }
86 342
 
343
+    pub(crate) fn set_app_settings_team_id(
344
+        &mut self,
345
+        updated_team_id: SharedString,
346
+        cx: &mut Context<Self>,
347
+    ) {
348
+        let SettingsWindowRequest::AppSettings { team_id, .. } = &mut self.request else {
349
+            return;
350
+        };
351
+        if team_id.as_ref() != updated_team_id.as_ref() {
352
+            *team_id = updated_team_id;
353
+            cx.notify();
354
+        }
355
+    }
356
+
357
+    fn toggle_remember_developer_account(
358
+        &mut self,
359
+        _: &ClickEvent,
360
+        window: &mut Window,
361
+        cx: &mut Context<Self>,
362
+    ) {
363
+        window.focus(&self.focus_handle, cx);
364
+        if matches!(self.request, SettingsWindowRequest::DeveloperLogin { .. }) {
365
+            self.developer_login.remember_account = !self.developer_login.remember_account;
366
+            cx.notify();
367
+        }
368
+    }
369
+
370
+    fn submit_developer_login(
371
+        &mut self,
372
+        _: &ClickEvent,
373
+        window: &mut Window,
374
+        cx: &mut Context<Self>,
375
+    ) {
376
+        window.focus(&self.focus_handle, cx);
377
+        self.submit_developer_login_inner(window, cx);
378
+    }
379
+
380
+    fn submit_developer_login_inner(&mut self, window: &mut Window, cx: &mut Context<Self>) {
381
+        let SettingsWindowRequest::DeveloperLogin { parent } = &self.request else {
382
+            return;
383
+        };
384
+        let parent = parent.clone();
385
+        let email = self.developer_login.email.read(cx).value();
386
+        let password = self.developer_login.password.read(cx).value();
387
+
388
+        match mock_developer_login(&email, &password) {
389
+            Ok(MockDeveloperLoginOutcome::RequiresTwoFactor { detail }) => {
390
+                self.developer_login.step = DeveloperLoginStep::TwoFactor;
391
+                self.developer_login.two_factor_detail = detail;
392
+                self.developer_login.error = None;
393
+                self.developer_login.code.update(cx, |code, cx| {
394
+                    *code = EditLine::new("", cx);
395
+                });
396
+                let code = self.developer_login.code.clone();
397
+                code.update(cx, |code, cx| code.focus(window, cx));
398
+                cx.notify();
399
+            }
400
+            Ok(MockDeveloperLoginOutcome::SignedIn(account)) => {
401
+                self.finish_developer_login(account, parent, window, cx);
402
+            }
403
+            Err(error) => {
404
+                self.developer_login.error = Some(error.into());
405
+                cx.notify();
406
+            }
407
+        }
408
+    }
409
+
410
+    fn submit_developer_two_factor(
411
+        &mut self,
412
+        _: &ClickEvent,
413
+        window: &mut Window,
414
+        cx: &mut Context<Self>,
415
+    ) {
416
+        window.focus(&self.focus_handle, cx);
417
+        self.submit_developer_two_factor_inner(window, cx);
418
+    }
419
+
420
+    fn submit_developer_two_factor_inner(&mut self, window: &mut Window, cx: &mut Context<Self>) {
421
+        let SettingsWindowRequest::DeveloperLogin { parent } = &self.request else {
422
+            return;
423
+        };
424
+        let parent = parent.clone();
425
+        let email = self.developer_login.email.read(cx).value();
426
+        let code = self.developer_login.code.read(cx).value();
427
+
428
+        match mock_developer_login_with_code(&email, &code) {
429
+            Ok(account) => self.finish_developer_login(account, parent, window, cx),
430
+            Err(error) => {
431
+                self.developer_login.error = Some(error.into());
432
+                cx.notify();
433
+            }
434
+        }
435
+    }
436
+
437
+    fn back_to_developer_login(
438
+        &mut self,
439
+        _: &ClickEvent,
440
+        window: &mut Window,
441
+        cx: &mut Context<Self>,
442
+    ) {
443
+        window.focus(&self.focus_handle, cx);
444
+        if matches!(self.request, SettingsWindowRequest::DeveloperLogin { .. }) {
445
+            self.developer_login.step = DeveloperLoginStep::Credentials;
446
+            self.developer_login.error = None;
447
+            cx.notify();
448
+        }
449
+    }
450
+
451
+    fn handle_developer_login_key(
452
+        &mut self,
453
+        event: &KeyDownEvent,
454
+        window: &mut Window,
455
+        cx: &mut Context<Self>,
456
+    ) {
457
+        if !matches!(self.request, SettingsWindowRequest::DeveloperLogin { .. }) {
458
+            return;
459
+        }
460
+
461
+        if event.keystroke.key.as_str() == "enter" {
462
+            cx.stop_propagation();
463
+            window.prevent_default();
464
+            match self.developer_login.step {
465
+                DeveloperLoginStep::Credentials => {
466
+                    self.submit_developer_login_inner(window, cx);
467
+                }
468
+                DeveloperLoginStep::TwoFactor => {
469
+                    self.submit_developer_two_factor_inner(window, cx);
470
+                }
471
+            }
472
+        }
473
+    }
474
+
475
+    fn finish_developer_login(
476
+        &mut self,
477
+        mut account: AccountOption,
478
+        parent: WeakEntity<SideloaderView>,
479
+        window: &mut Window,
480
+        cx: &mut Context<Self>,
481
+    ) {
482
+        account.detail = if self.developer_login.remember_account {
483
+            "Mock login session, marked to remember".into()
484
+        } else {
485
+            "Mock login session".into()
486
+        };
487
+
488
+        match parent.update(cx, |view, cx| {
489
+            view.add_developer_account_from_settings(account, cx)
490
+        }) {
491
+            Ok(Some(request)) => self.show_request(request, window, cx),
492
+            Ok(None) => {
493
+                self.developer_login.error =
494
+                    Some("The mock account has no developer teams.".into());
495
+                cx.notify();
496
+            }
497
+            Err(_) => {
498
+                self.developer_login.error =
499
+                    Some("The main sideloader window is not available.".into());
500
+                cx.notify();
501
+            }
502
+        }
503
+    }
504
+
87 505
     fn select_team(
88 506
         &mut self,
89 507
         index: usize,
... ...
@@ -111,10 +529,12 @@ impl SettingsWindow {
111 529
         self.team_picker_open = false;
112 530
         self.app_id_picker_open = false;
113 531
         let _ = parent.update(cx, |view, cx| {
114
-            let team_count = view.accounts[view.selected_account].teams.len();
115
-            if index < team_count {
116
-                view.selected_team = index;
117
-                cx.notify();
532
+            if let Some(team_id) = view.select_team_from_settings(index, cx) {
533
+                if let Some(handle) = view.app_settings_window.clone() {
534
+                    let _ = handle.update(cx, |settings, _, cx| {
535
+                        settings.set_app_settings_team_id(team_id.clone(), cx);
536
+                    });
537
+                }
118 538
             }
119 539
         });
120 540
         cx.notify();
... ...
@@ -131,12 +551,21 @@ impl SettingsWindow {
131 551
 
132 552
     fn toggle_auto_app_id(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
133 553
         window.focus(&self.focus_handle, cx);
134
-        let SettingsWindowRequest::Team { auto_app_id, .. } = &mut self.request else {
554
+        let SettingsWindowRequest::Team {
555
+            parent,
556
+            auto_app_id,
557
+            ..
558
+        } = &mut self.request
559
+        else {
135 560
             return;
136 561
         };
137 562
 
138 563
         *auto_app_id = !*auto_app_id;
564
+        let auto_app_id = *auto_app_id;
139 565
         self.app_id_picker_open = false;
566
+        let _ = parent.update(cx, |view, cx| {
567
+            view.set_auto_app_id_from_settings(auto_app_id, cx);
568
+        });
140 569
         cx.notify();
141 570
     }
142 571
 
... ...
@@ -167,6 +596,7 @@ impl SettingsWindow {
167 596
     ) {
168 597
         window.focus(&self.focus_handle, cx);
169 598
         let SettingsWindowRequest::Team {
599
+            parent,
170 600
             teams,
171 601
             selected_team,
172 602
             selected_app_id,
... ...
@@ -183,6 +613,9 @@ impl SettingsWindow {
183 613
         if index < team.app_ids.len() {
184 614
             *selected_app_id = index;
185 615
             self.app_id_picker_open = false;
616
+            let _ = parent.update(cx, |view, cx| {
617
+                view.select_app_id_from_settings(index, cx);
618
+            });
186 619
             cx.notify();
187 620
         }
188 621
     }
... ...
@@ -216,6 +649,20 @@ impl SettingsWindow {
216 649
         }
217 650
     }
218 651
 
652
+    fn log_out_developer_account(
653
+        &mut self,
654
+        _: &ClickEvent,
655
+        window: &mut Window,
656
+        cx: &mut Context<Self>,
657
+    ) {
658
+        window.focus(&self.focus_handle, cx);
659
+        if matches!(self.request, SettingsWindowRequest::Team { .. }) {
660
+            self.team_picker_open = false;
661
+            self.app_id_picker_open = false;
662
+            cx.notify();
663
+        }
664
+    }
665
+
219 666
     fn select_adi_backend(
220 667
         &mut self,
221 668
         index: usize,
... ...
@@ -224,6 +671,9 @@ impl SettingsWindow {
224 671
         cx: &mut Context<Self>,
225 672
     ) {
226 673
         window.focus(&self.focus_handle, cx);
674
+        if self.adi_operation.is_some() {
675
+            return;
676
+        }
227 677
         let SettingsWindowRequest::AdiSettings {
228 678
             parent,
229 679
             backends,
... ...
@@ -241,10 +691,7 @@ impl SettingsWindow {
241 691
         *selected_backend = index;
242 692
         self.adi_backend_picker_open = false;
243 693
         let _ = parent.update(cx, |view, cx| {
244
-            if index < view.adi_backends.len() {
245
-                view.selected_adi_backend = index;
246
-                cx.notify();
247
-            }
694
+            view.select_adi_backend_from_settings(index, cx);
248 695
         });
249 696
         cx.notify();
250 697
     }
... ...
@@ -256,1479 +703,1406 @@ impl SettingsWindow {
256 703
         cx: &mut Context<Self>,
257 704
     ) {
258 705
         window.focus(&self.focus_handle, cx);
706
+        if self.adi_operation.is_some() {
707
+            return;
708
+        }
259 709
         if matches!(self.request, SettingsWindowRequest::AdiSettings { .. }) {
260 710
             self.adi_backend_picker_open = !self.adi_backend_picker_open;
261 711
             cx.notify();
262 712
         }
263 713
     }
264 714
 
265
-    fn repair_adi_backend(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
715
+    fn open_data_folder(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
266 716
         window.focus(&self.focus_handle, cx);
267
-        cx.notify();
717
+        self.adi_backend_picker_open = false;
718
+        if let Err(error) = open_app_data_folder() {
719
+            eprintln!("{error}");
268 720
         }
269
-
270
-    fn edit_machine_identity(
271
-        &mut self,
272
-        _: &ClickEvent,
273
-        window: &mut Window,
274
-        cx: &mut Context<Self>,
275
-    ) {
276
-        window.focus(&self.focus_handle, cx);
277 721
         cx.notify();
278 722
     }
279 723
 
280
-    fn edit_app_icon(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
724
+    fn repair_adi_backend(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
281 725
         window.focus(&self.focus_handle, cx);
282
-        cx.notify();
726
+        if self.adi_operation.is_some() {
727
+            return;
283 728
         }
729
+        let Some((kind, _, _)) = self.selected_adi_backend_context() else {
730
+            return;
731
+        };
284 732
 
285
-    fn edit_app_detail(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
286
-        window.focus(&self.focus_handle, cx);
733
+        if kind != AdiBackendKind::AndroidCoreAdi {
287 734
             cx.notify();
735
+            return;
288 736
         }
289 737
 
290
-    fn revert_app_detail(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
291
-        window.focus(&self.focus_handle, cx);
292
-        cx.notify();
738
+        self.adi_backend_picker_open = false;
739
+        self.adi_operation = Some(AdiOperationState::DownloadingCoreAdi {
740
+            downloaded_bytes: 0,
741
+            total_bytes: None,
742
+        });
743
+        let (progress_sender, mut progress_receiver) = mpsc::unbounded();
744
+        cx.spawn(async move |settings, cx| {
745
+            while let Some(event) = progress_receiver.next().await {
746
+                let _ = settings.update(cx, |settings, cx| {
747
+                    settings.set_coreadi_install_event(event, cx);
748
+                });
293 749
             }
750
+        })
751
+        .detach();
752
+        cx.spawn(async move |settings, cx| {
753
+            let result = cx
754
+                .background_spawn(async move {
755
+                    adi_services::download_and_install_coreadi(move |event| {
756
+                        let _ = progress_sender.unbounded_send(event);
757
+                    })
758
+                })
759
+                .await;
294 760
 
295
-    fn add_entitlement(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
296
-        window.focus(&self.focus_handle, cx);
297
-        cx.notify();
761
+            let _ = settings.update(cx, |settings, cx| match result {
762
+                Ok(_) => {
763
+                    settings.adi_operation = None;
764
+                    settings.refresh_adi_backend_options(Some(AdiBackendKind::AndroidCoreAdi), cx)
298 765
                 }
299
-
300
-    fn remove_entitlement(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
301
-        window.focus(&self.focus_handle, cx);
766
+                Err(error) => {
767
+                    settings.adi_operation = None;
768
+                    settings.set_selected_adi_provisioning_state(
769
+                        AdiProvisioningState::Error(error.into()),
770
+                        cx,
771
+                    );
772
+                }
773
+            });
774
+        })
775
+        .detach();
302 776
         cx.notify();
303 777
     }
304 778
 
305
-    fn revert_entitlements(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
779
+    fn select_coreadi_apk(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
306 780
         window.focus(&self.focus_handle, cx);
307
-        cx.notify();
781
+        if self.adi_operation.is_some() {
782
+            return;
308 783
         }
784
+        let receiver = cx.prompt_for_paths(PathPromptOptions {
785
+            files: true,
786
+            directories: false,
787
+            multiple: false,
788
+            prompt: Some("Select APK".into()),
789
+        });
790
+
791
+        cx.spawn(async move |settings, cx| {
792
+            let paths = match receiver.await {
793
+                Ok(Ok(Some(paths))) => paths,
794
+                Ok(Ok(None)) | Err(_) => return,
795
+                Ok(Err(error)) => {
796
+                    let _ = settings.update(cx, |settings, cx| {
797
+                        settings.set_selected_adi_provisioning_state(
798
+                            AdiProvisioningState::Error(error.to_string().into()),
799
+                            cx,
800
+                        );
801
+                    });
802
+                    return;
309 803
                 }
804
+            };
310 805
 
311
-impl Render for SettingsWindow {
312
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
313
-        let team_picker_open = self.team_picker_open;
314
-        let app_id_picker_open = self.app_id_picker_open;
315
-        let adi_backend_picker_open = self.adi_backend_picker_open;
806
+            let Some(apk_path) = paths.into_iter().next() else {
807
+                return;
808
+            };
316 809
 
317
-        match &mut self.request {
318
-            SettingsWindowRequest::Team {
319
-                teams,
320
-                selected_team,
321
-                auto_app_id,
322
-                selected_app_id,
323
-                ..
324
-            } => {
325
-                let selected = *selected_team;
326
-                let auto_app_id = *auto_app_id;
327
-                let selected_app_id = *selected_app_id;
328
-                let team = teams.get(selected);
329
-                let mut content = div()
330
-                    .flex()
331
-                    .flex_col()
332
-                    .gap_4()
333
-                    .when_some(team, |this, team| {
334
-                        this.child(developer_settings_section(
335
-                            "Developer Team",
336
-                            "Team used for certificates, profiles, and signing identities.",
337
-                            div()
338
-                                .flex()
339
-                                .flex_col()
340
-                                .gap_3()
341
-                                .child(combo_with_popover(
342
-                                    "team-popover-scroll",
343
-                                    combo_button(
344
-                                        team.identifier.clone(),
345
-                                        team.name.clone(),
346
-                                        developer_account_title(&team.role),
347
-                                        team_picker_open,
348
-                                    )
349
-                                    .id("settings-team-combobox")
350
-                                    .on_click(cx.listener(Self::toggle_team_picker)),
351
-                                    team_picker_open,
352
-                                    dropdown_list((0..teams.len()).map(|index| {
353
-                                        settings_team_row(&teams[index], index == selected)
354
-                                            .id(("settings-team-row", index))
355
-                                            .on_click(cx.listener(
356
-                                                move |this, event, window, cx| {
357
-                                                    this.select_team(index, event, window, cx)
358
-                                                },
359
-                                            ))
360
-                                    }))
361
-                                    .id("settings-team-options"),
362
-                                ))
363
-                                .child(properties_list(vec![
364
-                                    ("Name", team.name.to_string()),
365
-                                    ("Team ID", team.identifier.to_string()),
366
-                                    ("Role", team.role.to_string()),
367
-                                ])),
368
-                        ))
810
+            let _ = settings.update(cx, |settings, cx| {
811
+                settings.adi_operation = Some(AdiOperationState::InstallingCoreAdi);
812
+                cx.notify();
369 813
             });
370
-
371
-                if let Some(team) = team {
372
-                    content = content.child(app_id_section(
373
-                        team,
374
-                        auto_app_id,
375
-                        selected_app_id,
376
-                        app_id_picker_open,
814
+            let result = cx
815
+                .background_spawn(async move { adi_services::install_coreadi_from_apk(&apk_path) })
816
+                .await;
817
+            let _ = settings.update(cx, |settings, cx| match result {
818
+                Ok(_) => {
819
+                    settings.adi_operation = None;
820
+                    settings.refresh_adi_backend_options(Some(AdiBackendKind::AndroidCoreAdi), cx)
821
+                }
822
+                Err(error) => {
823
+                    settings.adi_operation = None;
824
+                    settings.set_selected_adi_provisioning_state(
825
+                        AdiProvisioningState::Error(error.into()),
377 826
                         cx,
378
-                    ));
827
+                    );
828
+                }
829
+            });
830
+        })
831
+        .detach();
379 832
     }
380 833
 
381
-                settings_window_shell()
382
-                    .track_focus(&self.focus_handle)
383
-                    .gap_4()
384
-                    .child(settings_window_header_with_action(
385
-                        settings_window_header(
386
-                            "Developer Settings",
387
-                            "Configure developer-team resources used during signing.",
388
-                        ),
389
-                        team_refresh_button(cx),
390
-                    ))
391
-                    .child(scroll_panel(
392
-                        "team-settings-scroll",
393
-                        &self.scroll_handle,
394
-                        content,
395
-                    ))
834
+    fn erase_adi_backend_provisioning(
835
+        &mut self,
836
+        _: &ClickEvent,
837
+        window: &mut Window,
838
+        cx: &mut Context<Self>,
839
+    ) {
840
+        window.focus(&self.focus_handle, cx);
841
+        if self.adi_operation.is_some() {
842
+            return;
396 843
         }
397
-            SettingsWindowRequest::AppSettings {
398
-                app,
399
-                enabled_patches,
400
-                ..
401
-            } => {
402
-                let patch_summary = enabled_patch_summary(app, enabled_patches);
844
+        let Some((kind, _machine_identity, android_adi_identifier)) =
845
+            self.selected_adi_backend_context()
846
+        else {
847
+            return;
848
+        };
403 849
 
404
-                settings_window_shell()
405
-                    .track_focus(&self.focus_handle)
406
-                    .child(scroll_panel(
407
-                        "app-settings-scroll",
408
-                        &self.scroll_handle,
409
-                        div()
410
-                            .flex()
411
-                            .flex_col()
412
-                            .gap_4()
413
-                            .child(settings_window_header(
414
-                                "App Settings",
415
-                                "Review metadata and entitlements before signing.",
416
-                            ))
417
-                            .child(
418
-                                div()
419
-                                    .flex()
420
-                                    .gap_3()
421
-                                    .p_3()
422
-                                    .rounded_md()
423
-                                    .border_1()
424
-                                    .border_color(rgb(0xd8e0df))
425
-                                    .bg(rgb(0xffffff))
426
-                                    .child(app_icon_editor(app, cx))
427
-                                    .child(
428
-                                        div()
429
-                                            .min_w_0()
430
-                                            .flex()
431
-                                            .flex_1()
432
-                                            .flex_col()
433
-                                            .gap_2()
434
-                                            .child(app_detail_row(
435
-                                                "Name",
436
-                                                app.name.to_string(),
437
-                                                true,
438
-                                                cx,
439
-                                            ))
440
-                                            .child(app_detail_row(
441
-                                                "Bundle ID",
442
-                                                app.bundle_id.to_string(),
443
-                                                true,
444
-                                                cx,
445
-                                            ))
446
-                                            .child(app_detail_row(
447
-                                                "Version",
448
-                                                app.version.to_string(),
449
-                                                true,
450
-                                                cx,
451
-                                            ))
452
-                                            .child(app_detail_row(
453
-                                                "Build",
454
-                                                app.build.to_string(),
455
-                                                true,
850
+        match adi_services::erase_provisioning(kind, &android_adi_identifier) {
851
+            Ok(()) => self.refresh_adi_backend_options(Some(kind), cx),
852
+            Err(error) => self
853
+                .set_selected_adi_provisioning_state(AdiProvisioningState::Error(error.into()), cx),
854
+        }
855
+    }
856
+
857
+    fn provision_adi_backend(
858
+        &mut self,
859
+        _: &ClickEvent,
860
+        window: &mut Window,
861
+        cx: &mut Context<Self>,
862
+    ) {
863
+        window.focus(&self.focus_handle, cx);
864
+        if self.adi_operation.is_some() {
865
+            return;
866
+        }
867
+        let Some((kind, machine_identity, android_adi_identifier)) =
868
+            self.selected_adi_backend_context()
869
+        else {
870
+            return;
871
+        };
872
+
873
+        self.adi_backend_picker_open = false;
874
+        self.adi_operation = Some(AdiOperationState::Provisioning);
875
+        cx.spawn(async move |settings, cx| {
876
+            let result = cx
877
+                .background_spawn(async move {
878
+                    adi_services::provision(kind, &machine_identity, &android_adi_identifier)
879
+                })
880
+                .await;
881
+            let _ = settings.update(cx, |settings, cx| {
882
+                settings.adi_operation = None;
883
+                match result {
884
+                    Ok(()) => settings.refresh_adi_backend_options(Some(kind), cx),
885
+                    Err(error) => settings.set_selected_adi_provisioning_state(
886
+                        AdiProvisioningState::Error(error.into()),
456 887
                         cx,
457
-                                            )),
458 888
                     ),
459
-                            )
460
-                            .child(
461
-                                div()
462
-                                    .flex()
463
-                                    .flex_col()
464
-                                    .gap_2()
465
-                                    .p_3()
466
-                                    .rounded_md()
467
-                                    .border_1()
468
-                                    .border_color(rgb(0xd8e0df))
469
-                                    .bg(rgb(0xffffff))
470
-                                    .child(app_detail_row("IPA", app.path.to_string(), false, cx))
471
-                                    .child(app_detail_row(
472
-                                        "Executable",
473
-                                        app.name.to_string().replace(' ', ""),
474
-                                        true,
475
-                                        cx,
476
-                                    ))
477
-                                    .child(app_detail_row(
478
-                                        "Minimum OS",
479
-                                        "iOS 15.0".to_string(),
480
-                                        true,
481
-                                        cx,
482
-                                    ))
483
-                                    .child(app_detail_row(
484
-                                        "Supported devices",
485
-                                        "iPhone, iPad".to_string(),
486
-                                        true,
487
-                                        cx,
488
-                                    ))
489
-                                    .child(app_detail_row("Patches", patch_summary, false, cx)),
490
-                            )
491
-                            .child(entitlements_table(app, cx)),
492
-                    ))
493 889
                 }
494
-            SettingsWindowRequest::AdiSettings {
890
+            });
891
+        })
892
+        .detach();
893
+        cx.notify();
894
+    }
895
+
896
+    fn selected_adi_backend_context(&self) -> Option<(AdiBackendKind, MachineIdentity, String)> {
897
+        let SettingsWindowRequest::AdiSettings {
495 898
             backends,
496 899
             selected_backend,
497 900
             machine_identity,
901
+            android_device_identity,
902
+            android_adi_identifier,
498 903
             ..
499
-            } => {
500
-                let selected = *selected_backend;
501
-                let backend = backends.get(selected);
502
-                let editable_identity = backend
503
-                    .map(|backend| backend.editable_identity)
504
-                    .unwrap_or(false);
904
+        } = &self.request
905
+        else {
906
+            return None;
907
+        };
505 908
 
506
-                let mut content = div()
507
-                    .flex()
508
-                    .flex_col()
509
-                    .gap_4()
510
-                    .child(settings_window_header(
511
-                        "Settings",
512
-                        "Choose the ADI backend and review the machine identity it uses.",
513
-                    ))
514
-                    .child(
515
-                        div()
516
-                            .flex()
517
-                            .flex_col()
518
-                            .gap_2()
519
-                            .child(
520
-                                div()
521
-                                    .text_xs()
522
-                                    .font_weight(FontWeight::SEMIBOLD)
523
-                                    .text_color(rgb(0x66767c))
524
-                                    .child("ADI Backend"),
525
-                            )
526
-                            .when_some(backend, |this, backend| {
527
-                                this.child(settings_backend_combo_with_popover(
528
-                                    settings_adi_backend_combo_button(
529
-                                        backend,
530
-                                        adi_backend_picker_open,
531
-                                    )
532
-                                    .id("adi-backend-combobox")
533
-                                    .on_click(cx.listener(Self::toggle_adi_backend_picker)),
534
-                                    adi_backend_picker_open,
535
-                                    dropdown_list((0..backends.len()).map(|index| {
536
-                                        settings_adi_backend_option(
537
-                                            &backends[index],
538
-                                            index == selected,
539
-                                        )
540
-                                        .id(("adi-backend-option", index))
541
-                                        .on_click(
542
-                                            cx.listener(move |this, event, window, cx| {
543
-                                                this.select_adi_backend(index, event, window, cx)
544
-                                            }),
909
+        backends.get(*selected_backend).map(|backend| {
910
+            let machine_identity = match backend.kind {
911
+                AdiBackendKind::AndroidCoreAdi => android_device_identity.clone(),
912
+                AdiBackendKind::SystemAdid | AdiBackendKind::WindowsCoreAdi => {
913
+                    machine_identity.clone()
914
+                }
915
+            };
916
+            (
917
+                backend.kind,
918
+                machine_identity,
919
+                android_adi_identifier.clone(),
545 920
             )
546
-                                    }))
547
-                                    .id("adi-backend-options"),
548
-                                ))
549
-                            }),
550
-                    );
921
+        })
922
+    }
923
+
924
+    fn refresh_adi_backend_options(
925
+        &mut self,
926
+        preferred_kind: Option<AdiBackendKind>,
927
+        cx: &mut Context<Self>,
928
+    ) {
929
+        let SettingsWindowRequest::AdiSettings {
930
+            parent,
931
+            backends,
932
+            selected_backend,
933
+            android_adi_identifier,
934
+            ..
935
+        } = &mut self.request
936
+        else {
937
+            return;
938
+        };
939
+
940
+        let selected_kind =
941
+            preferred_kind.or_else(|| backends.get(*selected_backend).map(|backend| backend.kind));
942
+        let refreshed = adi_services::available_backends(android_adi_identifier);
943
+        let selected = selected_kind
944
+            .and_then(|kind| refreshed.iter().position(|backend| backend.kind == kind))
945
+            .unwrap_or_else(|| adi_services::default_backend(&refreshed));
946
+
947
+        *backends = refreshed.clone();
948
+        *selected_backend = selected;
551 949
 
552
-                if let Some(backend) = backend {
553
-                    content = content.child(adi_backend_status_section(backend, cx));
950
+        let _ = parent.update(cx, |view, cx| {
951
+            view.replace_adi_backends_from_settings(refreshed, selected, true, cx);
952
+        });
953
+        cx.notify();
554 954
     }
555 955
 
556
-                content = content.child(machine_identity_section(
557
-                    machine_identity,
558
-                    editable_identity,
559
-                    cx,
560
-                ));
956
+    fn set_selected_adi_provisioning_state(
957
+        &mut self,
958
+        state: AdiProvisioningState,
959
+        cx: &mut Context<Self>,
960
+    ) {
961
+        let SettingsWindowRequest::AdiSettings {
962
+            parent,
963
+            backends,
964
+            selected_backend,
965
+            ..
966
+        } = &mut self.request
967
+        else {
968
+            return;
969
+        };
561 970
 
562
-                settings_window_shell()
563
-                    .track_focus(&self.focus_handle)
564
-                    .child(scroll_panel(
565
-                        "adi-settings-scroll",
566
-                        &self.scroll_handle,
567
-                        content,
568
-                    ))
971
+        if let Some(backend) = backends.get_mut(*selected_backend) {
972
+            backend.provisioning_state = state.clone();
973
+        }
974
+
975
+        let backends = backends.clone();
976
+        let selected = *selected_backend;
977
+        let _ = parent.update(cx, |view, cx| {
978
+            view.replace_adi_backends_from_settings(backends, selected, false, cx);
979
+        });
980
+        cx.notify();
569 981
     }
982
+
983
+    fn set_coreadi_install_event(&mut self, event: CoreAdiInstallEvent, cx: &mut Context<Self>) {
984
+        self.adi_operation = Some(match event {
985
+            CoreAdiInstallEvent::Downloading(progress) => AdiOperationState::DownloadingCoreAdi {
986
+                downloaded_bytes: progress.downloaded_bytes,
987
+                total_bytes: progress.total_bytes,
988
+            },
989
+            CoreAdiInstallEvent::Installing => AdiOperationState::InstallingCoreAdi,
990
+        });
991
+        cx.notify();
570 992
     }
993
+
994
+    fn begin_machine_identity_edit(
995
+        &mut self,
996
+        field: MachineIdentityField,
997
+        _: &ClickEvent,
998
+        window: &mut Window,
999
+        cx: &mut Context<Self>,
1000
+    ) {
1001
+        window.focus(&self.focus_handle, cx);
1002
+        if self.adi_operation.is_some() {
1003
+            return;
571 1004
         }
1005
+        let Some(value) = self.editable_machine_identity_value(field).cloned() else {
1006
+            return;
1007
+        };
1008
+        let input = cx.new(|cx| EditLine::new(value, cx));
1009
+        self.machine_identity_edit = Some(MachineIdentityEdit {
1010
+            field,
1011
+            input: input.clone(),
1012
+        });
1013
+        input.update(cx, |input, cx| input.focus(window, cx));
1014
+        cx.notify();
572 1015
     }
573 1016
 
574
-pub(crate) fn show_or_open_settings_window(
575
-    handle: Option<WindowHandle<SettingsWindow>>,
576
-    request: SettingsWindowRequest,
577
-    width: f32,
578
-    height: f32,
579
-    cx: &mut App,
580
-) -> WindowHandle<SettingsWindow> {
581
-    if let Some(handle) = handle {
582
-        if handle
583
-            .update(cx, |settings, window, cx| {
584
-                settings.show_request(request.clone(), window, cx);
585
-            })
586
-            .is_ok()
587
-        {
588
-            return handle;
1017
+    fn save_machine_identity_edit_from_button(
1018
+        &mut self,
1019
+        _: &ClickEvent,
1020
+        window: &mut Window,
1021
+        cx: &mut Context<Self>,
1022
+    ) {
1023
+        window.focus(&self.focus_handle, cx);
1024
+        self.save_machine_identity_edit(cx);
589 1025
     }
1026
+
1027
+    fn cancel_machine_identity_edit(
1028
+        &mut self,
1029
+        _: &ClickEvent,
1030
+        window: &mut Window,
1031
+        cx: &mut Context<Self>,
1032
+    ) {
1033
+        window.focus(&self.focus_handle, cx);
1034
+        self.machine_identity_edit = None;
1035
+        cx.notify();
590 1036
     }
591 1037
 
592
-    open_settings_window(request, width, height, cx)
1038
+    fn handle_machine_identity_editor_key(
1039
+        &mut self,
1040
+        event: &KeyDownEvent,
1041
+        window: &mut Window,
1042
+        cx: &mut Context<Self>,
1043
+    ) {
1044
+        if self.machine_identity_edit.is_none() {
1045
+            return;
593 1046
         }
594 1047
 
595
-fn open_settings_window(
596
-    request: SettingsWindowRequest,
597
-    width: f32,
598
-    height: f32,
599
-    cx: &mut App,
600
-) -> WindowHandle<SettingsWindow> {
601
-    let window_size = size(px(width), px(height));
602
-    let bounds = Bounds::centered(None, window_size, cx);
603
-    let title = request.title();
1048
+        match event.keystroke.key.as_str() {
1049
+            "enter" => {
1050
+                cx.stop_propagation();
1051
+                window.prevent_default();
1052
+                self.save_machine_identity_edit(cx);
1053
+            }
1054
+            "escape" => {
1055
+                cx.stop_propagation();
1056
+                window.prevent_default();
1057
+                self.machine_identity_edit = None;
1058
+                cx.notify();
1059
+            }
1060
+            _ => {}
1061
+        }
1062
+    }
604 1063
 
605
-    cx.open_window(
606
-        WindowOptions {
607
-            window_bounds: Some(WindowBounds::Windowed(bounds)),
608
-            window_min_size: Some(window_size),
609
-            is_resizable: true,
610
-            is_minimizable: false,
611
-            kind: WindowKind::Floating,
612
-            ..Default::default()
613
-        },
614
-        |window, cx| {
615
-            window.set_window_title(title);
616
-            cx.new(|cx| SettingsWindow::new(request, window, cx))
617
-        },
618
-    )
619
-    .expect("failed to open settings window")
1064
+    fn editable_machine_identity_value(
1065
+        &self,
1066
+        field: MachineIdentityField,
1067
+    ) -> Option<&SharedString> {
1068
+        let SettingsWindowRequest::AdiSettings {
1069
+            backends,
1070
+            selected_backend,
1071
+            android_device_identity,
1072
+            ..
1073
+        } = &self.request
1074
+        else {
1075
+            return None;
1076
+        };
1077
+
1078
+        let backend = backends.get(*selected_backend)?;
1079
+        if backend.kind != AdiBackendKind::AndroidCoreAdi || !backend.editable_identity {
1080
+            return None;
620 1081
         }
621 1082
 
622
-fn scroll_panel(
623
-    scroll_id: &'static str,
624
-    scroll_handle: &ScrollHandle,
625
-    content: impl IntoElement,
626
-) -> gpui::Div {
627
-    let viewport_height = scroll_handle.bounds().size.height.as_f32();
628
-    let max_scroll = scroll_handle.max_offset().y.as_f32();
629
-    let is_scrollable = viewport_height > 0. && max_scroll > 0.5;
630
-    let scroll_offset = (-scroll_handle.offset().y.as_f32()).clamp(0., max_scroll);
631
-    let content_height = viewport_height + max_scroll;
632
-    let thumb_height = if is_scrollable {
633
-        ((viewport_height / content_height) * viewport_height).clamp(36., viewport_height)
634
-    } else {
635
-        0.
636
-    };
637
-    let thumb_top = if is_scrollable && max_scroll > 0. {
638
-        (scroll_offset / max_scroll) * (viewport_height - thumb_height)
639
-    } else {
640
-        0.
1083
+        Some(field.value(android_device_identity))
1084
+    }
1085
+
1086
+    fn save_machine_identity_edit(&mut self, cx: &mut Context<Self>) {
1087
+        let Some(edit) = self.machine_identity_edit.take() else {
1088
+            return;
641 1089
         };
642 1090
 
643
-    div()
644
-        .min_h_0()
645
-        .flex_1()
646
-        .flex()
647
-        .gap_2()
648
-        .child(
649
-            div()
650
-                .id(scroll_id)
651
-                .min_w_0()
652
-                .h_full()
653
-                .flex_1()
654
-                .overflow_y_scroll()
655
-                .scrollbar_width(px(1.))
656
-                .track_scroll(scroll_handle)
657
-                .child(content),
658
-        )
659
-        .when(is_scrollable, |this| {
660
-            this.child(
661
-                div()
662
-                    .flex_none()
663
-                    .w(px(6.))
664
-                    .h_full()
665
-                    .rounded_full()
666
-                    .bg(rgb(0xdde5e4))
667
-                    .flex()
668
-                    .flex_col()
669
-                    .child(div().flex_none().h(px(thumb_top)))
670
-                    .child(
671
-                        div()
672
-                            .flex_none()
673
-                            .w_full()
674
-                            .h(px(thumb_height))
675
-                            .rounded_full()
676
-                            .bg(rgb(0x789094)),
677
-                    )
678
-                    .child(div().flex_1()),
679
-            )
680
-        })
1091
+        let mut changed_identity = None;
1092
+        let mut should_erase_provisioning = false;
1093
+        let mut android_adi_identifier = String::new();
1094
+        let mut error = None;
1095
+
1096
+        if let SettingsWindowRequest::AdiSettings {
1097
+            parent,
1098
+            backends,
1099
+            selected_backend,
1100
+            android_device_identity,
1101
+            android_adi_identifier: request_android_adi_identifier,
1102
+            ..
1103
+        } = &mut self.request
1104
+        {
1105
+            let Some(backend) = backends.get(*selected_backend) else {
1106
+                cx.notify();
1107
+                return;
1108
+            };
1109
+            if backend.kind != AdiBackendKind::AndroidCoreAdi || !backend.editable_identity {
1110
+                cx.notify();
1111
+                return;
681 1112
             }
682 1113
 
683
-fn settings_window_shell() -> gpui::Div {
684
-    div()
685
-        .size_full()
686
-        .p_5()
687
-        .flex()
688
-        .flex_col()
689
-        .bg(rgb(0xf4f6f4))
690
-        .text_color(rgb(0x263238))
691
-        .font_family(".SystemUIFont")
1114
+            let new_value = edit.input.read(cx).value().to_string();
1115
+            let old_value = edit.field.value(android_device_identity).to_string();
1116
+            if old_value != new_value {
1117
+                edit.field.set_value(android_device_identity, new_value);
1118
+                changed_identity = Some((parent.clone(), android_device_identity.clone()));
1119
+                should_erase_provisioning = matches!(
1120
+                    backend.provisioning_state,
1121
+                    AdiProvisioningState::Provisioned
1122
+                );
1123
+                android_adi_identifier = request_android_adi_identifier.clone();
1124
+            }
692 1125
         }
693 1126
 
694
-fn settings_window_header(title: &'static str, detail: &'static str) -> gpui::Div {
695
-    div()
696
-        .flex()
697
-        .flex_col()
698
-        .gap_0p5()
699
-        .child(
700
-            div()
701
-                .text_sm()
702
-                .font_weight(FontWeight::BOLD)
703
-                .text_color(rgb(0x24333a))
704
-                .child(title),
1127
+        if let Some((parent, identity)) = changed_identity {
1128
+            let _ = parent.update(cx, |view, cx| {
1129
+                view.replace_android_device_identity_from_settings(identity, cx);
1130
+            });
1131
+
1132
+            if should_erase_provisioning {
1133
+                error = adi_services::erase_provisioning(
1134
+                    AdiBackendKind::AndroidCoreAdi,
1135
+                    &android_adi_identifier,
705 1136
                 )
706
-        .child(div().text_xs().text_color(rgb(0x6a7a81)).child(detail))
1137
+                .err();
707 1138
             }
708 1139
 
709
-fn settings_window_header_with_action(
710
-    header: impl IntoElement,
711
-    action: impl IntoElement,
712
-) -> gpui::Div {
713
-    div()
714
-        .flex()
715
-        .items_start()
716
-        .justify_between()
717
-        .gap_3()
718
-        .child(header)
719
-        .child(action)
1140
+            self.refresh_adi_backend_options(Some(AdiBackendKind::AndroidCoreAdi), cx);
1141
+            if let Some(error) = error {
1142
+                self.set_selected_adi_provisioning_state(
1143
+                    AdiProvisioningState::Error(error.into()),
1144
+                    cx,
1145
+                );
720 1146
             }
721
-
722
-fn team_refresh_button(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
723
-    div()
724
-        .id("refresh-team-details")
725
-        .flex_none()
726
-        .h_8()
727
-        .px_3()
728
-        .rounded_md()
729
-        .bg(rgb(0xebf1f0))
730
-        .text_color(rgb(0x53666d))
731
-        .text_xs()
732
-        .font_weight(FontWeight::SEMIBOLD)
733
-        .flex()
734
-        .items_center()
735
-        .justify_center()
736
-        .gap_2()
737
-        .cursor_pointer()
738
-        .hover(|style| style.bg(rgb(0xdfe8e6)))
739
-        .on_click(cx.listener(SettingsWindow::refresh_team_details))
740
-        .child(lucide_icon("icons/refresh-cw.svg"))
741
-        .child("Refresh")
1147
+        } else {
1148
+            cx.notify();
742 1149
         }
743
-
744
-fn settings_label(label: &'static str) -> gpui::Div {
745
-    div()
746
-        .text_xs()
747
-        .font_weight(FontWeight::SEMIBOLD)
748
-        .text_color(rgb(0x66767c))
749
-        .child(label)
750 1150
     }
751 1151
 
752
-fn developer_settings_section(
753
-    title: &'static str,
754
-    detail: &'static str,
755
-    body: impl IntoElement,
756
-) -> gpui::Div {
757
-    div()
758
-        .p_3()
759
-        .rounded_md()
760
-        .border_1()
761
-        .border_color(rgb(0xd8e0df))
762
-        .bg(rgb(0xffffff))
763
-        .flex()
764
-        .flex_col()
765
-        .gap_3()
766
-        .child(
767
-            div()
768
-                .flex()
769
-                .flex_col()
770
-                .gap_0p5()
771
-                .child(
772
-                    div()
773
-                        .text_sm()
774
-                        .font_weight(FontWeight::SEMIBOLD)
775
-                        .text_color(rgb(0x24333a))
776
-                        .child(title),
777
-                )
778
-                .child(div().text_xs().text_color(rgb(0x6a7a81)).child(detail)),
779
-        )
780
-        .child(body)
781
-}
1152
+    fn edit_app_icon(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1153
+        window.focus(&self.focus_handle, cx);
1154
+        let receiver = cx.prompt_for_paths(PathPromptOptions {
1155
+            files: true,
1156
+            directories: false,
1157
+            multiple: false,
1158
+            prompt: Some("Select App Icon".into()),
1159
+        });
782 1160
 
783
-fn settings_team_row(team: &TeamOption, selected: bool) -> gpui::Div {
784
-    div()
785
-        .h(px(COMBO_ITEM_HEIGHT))
786
-        .px_3()
787
-        .rounded_md()
788
-        .border_1()
789
-        .border_color(if selected {
790
-            rgb(0x0f6f7a)
791
-        } else {
792
-            rgb(0xd8e0df)
793
-        })
794
-        .bg(if selected {
795
-            rgb(0xf0fbfa)
796
-        } else {
797
-            rgb(0xffffff)
798
-        })
799
-        .cursor_pointer()
800
-        .hover(|style| style.bg(rgb(0xf6f9f9)))
801
-        .flex()
802
-        .items_center()
803
-        .child(combo_item_content(
804
-            team.identifier.clone(),
805
-            team.name.clone(),
806
-            developer_account_title(&team.role),
807
-        ))
1161
+        cx.spawn(async move |settings, cx| {
1162
+            let paths = match receiver.await {
1163
+                Ok(Ok(Some(paths))) => paths,
1164
+                Ok(Ok(None)) | Err(_) => return,
1165
+                Ok(Err(error)) => {
1166
+                    eprintln!("Failed to select app icon: {error}");
1167
+                    return;
808 1168
                 }
1169
+            };
809 1170
 
810
-fn app_id_section(
811
-    team: &TeamOption,
812
-    auto_app_id: bool,
813
-    selected_app_id: usize,
814
-    picker_open: bool,
815
-    cx: &mut Context<SettingsWindow>,
816
-) -> gpui::Div {
817
-    let manual_enabled = !auto_app_id;
818
-    let selected_app_id = selected_app_id.min(team.app_ids.len().saturating_sub(1));
819
-    let selected = team.app_ids.get(selected_app_id);
1171
+            let Some(path) = paths.into_iter().next() else {
1172
+                return;
1173
+            };
820 1174
 
821
-    developer_settings_section(
822
-        "App ID",
823
-        "Identifier used when creating or selecting the provisioning profile.",
824
-        div()
825
-            .flex()
826
-            .flex_col()
827
-            .gap_3()
828
-            .child(auto_app_id_checkbox(auto_app_id, cx))
829
-            .child(
830
-                div()
831
-                    .flex()
832
-                    .flex_col()
833
-                    .gap_2()
834
-                    .child(settings_label("Manual App ID"))
835
-                    .child(
836
-                        div()
837
-                            .flex()
838
-                            .items_center()
839
-                            .gap_2()
840
-                            .when_some(selected, |this, selected| {
841
-                                this.child(
842
-                                    div()
843
-                                        .min_w_0()
844
-                                        .flex_1()
845
-                                        .when(!manual_enabled, |this| this.opacity(0.5))
846
-                                        .child(combo_with_popover(
847
-                                            "app-id-popover-scroll",
848
-                                            combo_button(
849
-                                                selected.identifier.clone(),
850
-                                                selected.name.clone(),
851
-                                                selected.kind.clone(),
852
-                                                picker_open && manual_enabled,
853
-                                            )
854
-                                            .id("app-id-combobox")
855
-                                            .when(
856
-                                                manual_enabled,
857
-                                                |this| {
858
-                                                    this.on_click(cx.listener(
859
-                                                        SettingsWindow::toggle_app_id_picker,
860
-                                                    ))
861
-                                                },
862
-                                            ),
863
-                                            picker_open && manual_enabled,
864
-                                            dropdown_list((0..team.app_ids.len()).map(|index| {
865
-                                                settings_app_id_row(
866
-                                                    &team.app_ids[index],
867
-                                                    index == selected_app_id,
868
-                                                )
869
-                                                .id(("app-id-row", index))
870
-                                                .on_click(cx.listener(
871
-                                                    move |this, event, window, cx| {
872
-                                                        this.select_app_id(index, event, window, cx)
873
-                                                    },
874
-                                                ))
875
-                                            }))
876
-                                            .id("app-id-options"),
877
-                                        )),
878
-                                )
1175
+            let _ = settings.update(cx, |settings, cx| {
1176
+                if let SettingsWindowRequest::AppSettings {
1177
+                    parent,
1178
+                    app_index,
1179
+                    app,
1180
+                    ..
1181
+                } = &mut settings.request
1182
+                {
1183
+                    app.icon_override_path = Some(path.to_string_lossy().to_string().into());
1184
+                    let app_index = *app_index;
1185
+                    let updated = app.clone();
1186
+                    let _ = parent.update(cx, |view, cx| {
1187
+                        view.replace_app_from_settings(app_index, updated, cx);
1188
+                    });
1189
+                }
1190
+                cx.notify();
1191
+            });
879 1192
         })
880
-                            .child(app_id_actions(!manual_enabled, cx)),
881
-                    ),
882
-            ),
883
-    )
1193
+        .detach();
884 1194
     }
885 1195
 
886
-fn auto_app_id_checkbox(checked: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
887
-    div()
888
-        .id("auto-app-id")
889
-        .p_2()
890
-        .rounded_md()
891
-        .bg(rgb(0xf6f9f9))
892
-        .flex()
893
-        .items_center()
894
-        .gap_2()
895
-        .cursor_pointer()
896
-        .hover(|style| style.bg(rgb(0xebf1f0)))
897
-        .on_click(cx.listener(SettingsWindow::toggle_auto_app_id))
898
-        .child(
899
-            div()
900
-                .flex_none()
901
-                .w_4()
902
-                .h_4()
903
-                .rounded_sm()
904
-                .border_1()
905
-                .border_color(if checked {
906
-                    rgb(0x173f45)
907
-                } else {
908
-                    rgb(0xaebcba)
909
-                })
910
-                .bg(if checked {
911
-                    rgb(0x173f45)
1196
+    fn begin_app_detail_edit(
1197
+        &mut self,
1198
+        field: AppMetadataField,
1199
+        _: &ClickEvent,
1200
+        window: &mut Window,
1201
+        cx: &mut Context<Self>,
1202
+    ) {
1203
+        window.focus(&self.focus_handle, cx);
1204
+        let SettingsWindowRequest::AppSettings { app, .. } = &self.request else {
1205
+            return;
1206
+        };
1207
+        if field == AppMetadataField::SupportedDevices {
1208
+            self.app_detail_edit = Some(AppDetailEdit::SupportedDevices {
1209
+                selected: app.supported_devices().to_vec(),
1210
+            });
912 1211
         } else {
913
-                    rgb(0xffffff)
914
-                })
915
-                .flex()
916
-                .items_center()
917
-                .justify_center()
918
-                .when(checked, |this| {
919
-                    this.child(div().w_2().h_2().rounded_sm().bg(rgb(0xffffff)))
920
-                }),
921
-        )
922
-        .child(
923
-            div()
924
-                .min_w_0()
925
-                .flex()
926
-                .flex_1()
927
-                .flex_col()
928
-                .gap_0p5()
929
-                .child(
930
-                    div()
931
-                        .text_xs()
932
-                        .font_weight(FontWeight::SEMIBOLD)
933
-                        .text_color(rgb(0x24333a))
934
-                        .child("Automatically pick or create an App ID"),
935
-                )
936
-                .child(
937
-                    div()
938
-                        .text_xs()
939
-                        .text_color(rgb(0x6a7a81))
940
-                        .child("Manual App ID selection stays visible below."),
941
-                ),
942
-        )
1212
+            let input = cx.new(|cx| EditLine::new(app.field(field).value().clone(), cx));
1213
+            self.app_detail_edit = Some(AppDetailEdit::Text {
1214
+                field,
1215
+                input: input.clone(),
1216
+            });
1217
+            input.update(cx, |input, cx| input.focus(window, cx));
1218
+        }
1219
+        cx.notify();
943 1220
     }
944 1221
 
945
-fn app_id_actions(disabled: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
946
-    div()
947
-        .flex_none()
948
-        .flex()
949
-        .items_center()
950
-        .gap_1()
951
-        .child(app_id_icon_button(
952
-            "add-app-id",
953
-            "icons/plus.svg",
954
-            disabled,
955
-            cx.listener(SettingsWindow::add_app_id),
956
-        ))
957
-        .child(app_id_icon_button(
958
-            "remove-app-id",
959
-            "icons/minus.svg",
960
-            disabled,
961
-            cx.listener(SettingsWindow::remove_app_id),
962
-        ))
963
-        .child(app_id_icon_button(
964
-            "edit-app-id",
965
-            "icons/pencil.svg",
966
-            disabled,
967
-            cx.listener(SettingsWindow::edit_app_id),
968
-        ))
969
-}
970
-
971
-fn app_id_icon_button(
972
-    id: &'static str,
973
-    icon: &'static str,
974
-    disabled: bool,
975
-    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
976
-) -> impl IntoElement {
977
-    div()
978
-        .flex_none()
979
-        .w_6()
980
-        .h_6()
981
-        .rounded_md()
982
-        .bg(rgb(0xebf1f0))
983
-        .text_color(rgb(0x53666d))
984
-        .flex()
985
-        .items_center()
986
-        .justify_center()
987
-        .id(id)
988
-        .when(disabled, |this| this.opacity(0.45))
989
-        .when(!disabled, |this| {
990
-            this.cursor_pointer()
991
-                .hover(|style| style.bg(rgb(0xdfe8e6)))
992
-                .on_click(listener)
993
-        })
994
-        .child(lucide_icon(icon))
1222
+    fn toggle_supported_device_family(
1223
+        &mut self,
1224
+        family: SupportedDeviceFamily,
1225
+        _: &ClickEvent,
1226
+        window: &mut Window,
1227
+        cx: &mut Context<Self>,
1228
+    ) {
1229
+        window.focus(&self.focus_handle, cx);
1230
+        let Some(AppDetailEdit::SupportedDevices { selected }) = &mut self.app_detail_edit else {
1231
+            return;
1232
+        };
1233
+
1234
+        if let Some(index) = selected.iter().position(|candidate| *candidate == family) {
1235
+            selected.remove(index);
1236
+        } else {
1237
+            selected.push(family);
1238
+            selected.sort();
1239
+            selected.dedup();
1240
+        }
1241
+        cx.notify();
1242
+    }
1243
+
1244
+    fn save_app_detail_edit_from_button(
1245
+        &mut self,
1246
+        _: &ClickEvent,
1247
+        window: &mut Window,
1248
+        cx: &mut Context<Self>,
1249
+    ) {
1250
+        window.focus(&self.focus_handle, cx);
1251
+        self.save_app_detail_edit(cx);
1252
+    }
1253
+
1254
+    fn cancel_app_detail_edit(
1255
+        &mut self,
1256
+        _: &ClickEvent,
1257
+        window: &mut Window,
1258
+        cx: &mut Context<Self>,
1259
+    ) {
1260
+        window.focus(&self.focus_handle, cx);
1261
+        self.app_detail_edit = None;
1262
+        cx.notify();
1263
+    }
1264
+
1265
+    fn handle_app_detail_editor_key(
1266
+        &mut self,
1267
+        event: &KeyDownEvent,
1268
+        window: &mut Window,
1269
+        cx: &mut Context<Self>,
1270
+    ) {
1271
+        if self.app_detail_edit.is_none() {
1272
+            return;
1273
+        }
1274
+
1275
+        match event.keystroke.key.as_str() {
1276
+            "enter" => {
1277
+                cx.stop_propagation();
1278
+                window.prevent_default();
1279
+                self.save_app_detail_edit(cx);
1280
+            }
1281
+            "escape" => {
1282
+                cx.stop_propagation();
1283
+                window.prevent_default();
1284
+                self.app_detail_edit = None;
1285
+                cx.notify();
1286
+            }
1287
+            _ => {}
1288
+        }
1289
+    }
1290
+
1291
+    fn handle_app_settings_key(
1292
+        &mut self,
1293
+        event: &KeyDownEvent,
1294
+        window: &mut Window,
1295
+        cx: &mut Context<Self>,
1296
+    ) {
1297
+        if self.entitlement_edit.is_some() {
1298
+            self.handle_entitlement_editor_key(event, window, cx);
1299
+        } else {
1300
+            self.handle_app_detail_editor_key(event, window, cx);
1301
+        }
1302
+    }
1303
+
1304
+    fn save_app_detail_edit(&mut self, cx: &mut Context<Self>) {
1305
+        let Some(edit) = self.app_detail_edit.take() else {
1306
+            return;
1307
+        };
1308
+
1309
+        let SettingsWindowRequest::AppSettings {
1310
+            parent,
1311
+            app_index,
1312
+            app,
1313
+            ..
1314
+        } = &mut self.request
1315
+        else {
1316
+            cx.notify();
1317
+            return;
1318
+        };
1319
+
1320
+        match edit {
1321
+            AppDetailEdit::Text { field, input } => {
1322
+                let value = input.read(cx).value().to_string();
1323
+                app.set_field_override(field, value);
1324
+            }
1325
+            AppDetailEdit::SupportedDevices { selected } => {
1326
+                app.set_supported_devices_override(selected);
1327
+            }
1328
+        }
1329
+        let app_index = *app_index;
1330
+        let updated = app.clone();
1331
+        let _ = parent.update(cx, |view, cx| {
1332
+            view.replace_app_from_settings(app_index, updated, cx);
1333
+        });
1334
+        cx.notify();
1335
+    }
1336
+
1337
+    fn revert_app_detail_field(
1338
+        &mut self,
1339
+        field: AppMetadataField,
1340
+        _: &ClickEvent,
1341
+        window: &mut Window,
1342
+        cx: &mut Context<Self>,
1343
+    ) {
1344
+        window.focus(&self.focus_handle, cx);
1345
+        let SettingsWindowRequest::AppSettings {
1346
+            parent,
1347
+            app_index,
1348
+            app,
1349
+            ..
1350
+        } = &mut self.request
1351
+        else {
1352
+            return;
1353
+        };
1354
+
1355
+        app.clear_field_override(field);
1356
+        if self
1357
+            .app_detail_edit
1358
+            .as_ref()
1359
+            .is_some_and(|edit| edit.field() == field)
1360
+        {
1361
+            self.app_detail_edit = None;
1362
+        }
1363
+        let app_index = *app_index;
1364
+        let updated = app.clone();
1365
+        let _ = parent.update(cx, |view, cx| {
1366
+            view.replace_app_from_settings(app_index, updated, cx);
1367
+        });
1368
+        cx.notify();
1369
+    }
1370
+
1371
+    fn add_entitlement(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1372
+        window.focus(&self.focus_handle, cx);
1373
+        let SettingsWindowRequest::AppSettings {
1374
+            parent,
1375
+            app_index,
1376
+            app,
1377
+            team_id,
1378
+            ..
1379
+        } = &mut self.request
1380
+        else {
1381
+            return;
1382
+        };
1383
+
1384
+        let mut entitlements = app.effective_entitlements(team_id);
1385
+        entitlements.push(AppEntitlement {
1386
+            key: "new.entitlement".into(),
1387
+            value: EntitlementValue::String("".into()),
1388
+        });
1389
+        let selected = entitlements.len().saturating_sub(1);
1390
+        app.entitlement_overrides = Some(entitlements);
1391
+        let app_index = *app_index;
1392
+        let updated = app.clone();
1393
+        let _ = parent.update(cx, |view, cx| {
1394
+            view.replace_app_from_settings(app_index, updated, cx);
1395
+        });
1396
+        let input = cx.new(|cx| EditLine::new("new.entitlement", cx));
1397
+        self.selected_entitlement = Some(selected);
1398
+        self.entitlement_type_picker_open = false;
1399
+        self.entitlement_edit = Some(EntitlementEdit::Text {
1400
+            row: selected,
1401
+            field: EntitlementField::Key,
1402
+            input: input.clone(),
1403
+        });
1404
+        input.update(cx, |input, cx| input.focus(window, cx));
1405
+        cx.notify();
1406
+    }
1407
+
1408
+    fn remove_entitlement(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1409
+        window.focus(&self.focus_handle, cx);
1410
+        let SettingsWindowRequest::AppSettings {
1411
+            parent,
1412
+            app_index,
1413
+            app,
1414
+            team_id,
1415
+            ..
1416
+        } = &mut self.request
1417
+        else {
1418
+            return;
1419
+        };
1420
+
1421
+        let mut entitlements = app.effective_entitlements(team_id);
1422
+        if entitlements.is_empty() {
1423
+            return;
1424
+        }
1425
+        let index = self
1426
+            .selected_entitlement
1427
+            .filter(|index| *index < entitlements.len())
1428
+            .unwrap_or_else(|| entitlements.len() - 1);
1429
+        entitlements.remove(index);
1430
+        let entitlements_len = entitlements.len();
1431
+        app.entitlement_overrides = Some(entitlements);
1432
+        self.selected_entitlement = if entitlements_len == 0 {
1433
+            None
1434
+        } else {
1435
+            Some(index.min(entitlements_len - 1))
1436
+        };
1437
+        self.entitlement_edit = None;
1438
+        self.entitlement_type_picker_open = false;
1439
+        let app_index = *app_index;
1440
+        let updated = app.clone();
1441
+        let _ = parent.update(cx, |view, cx| {
1442
+            view.replace_app_from_settings(app_index, updated, cx);
1443
+        });
1444
+        cx.notify();
1445
+    }
1446
+
1447
+    fn revert_entitlements(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
1448
+        window.focus(&self.focus_handle, cx);
1449
+        let SettingsWindowRequest::AppSettings {
1450
+            parent,
1451
+            app_index,
1452
+            app,
1453
+            ..
1454
+        } = &mut self.request
1455
+        else {
1456
+            return;
1457
+        };
1458
+
1459
+        app.entitlement_overrides = None;
1460
+        self.selected_entitlement = None;
1461
+        self.entitlement_edit = None;
1462
+        self.entitlement_type_picker_open = false;
1463
+        let app_index = *app_index;
1464
+        let updated = app.clone();
1465
+        let _ = parent.update(cx, |view, cx| {
1466
+            view.replace_app_from_settings(app_index, updated, cx);
1467
+        });
1468
+        cx.notify();
1469
+    }
1470
+
1471
+    fn revert_entitlement(
1472
+        &mut self,
1473
+        row: usize,
1474
+        _: &ClickEvent,
1475
+        window: &mut Window,
1476
+        cx: &mut Context<Self>,
1477
+    ) {
1478
+        cx.stop_propagation();
1479
+        window.focus(&self.focus_handle, cx);
1480
+        let SettingsWindowRequest::AppSettings {
1481
+            parent,
1482
+            app_index,
1483
+            app,
1484
+            team_id,
1485
+            ..
1486
+        } = &mut self.request
1487
+        else {
1488
+            return;
1489
+        };
1490
+
1491
+        let mut entitlements = app.effective_entitlements(team_id);
1492
+        if row >= entitlements.len() {
1493
+            return;
1494
+        }
1495
+
1496
+        if let Some(default) = entitlement_revert_target(
1497
+            &entitlements[row],
1498
+            row,
1499
+            &app.default_effective_entitlements(team_id),
1500
+        ) {
1501
+            entitlements[row] = default;
1502
+        } else {
1503
+            entitlements.remove(row);
1504
+        }
1505
+
1506
+        let defaults = app.default_effective_entitlements(team_id);
1507
+        app.entitlement_overrides = (entitlements != defaults).then_some(entitlements);
1508
+        self.selected_entitlement = app.entitlement_overrides.as_ref().and_then(|entitlements| {
1509
+            (!entitlements.is_empty()).then_some(row.min(entitlements.len() - 1))
1510
+        });
1511
+        self.entitlement_edit = None;
1512
+        self.entitlement_type_picker_open = false;
1513
+        let app_index = *app_index;
1514
+        let updated = app.clone();
1515
+        let _ = parent.update(cx, |view, cx| {
1516
+            view.replace_app_from_settings(app_index, updated, cx);
1517
+        });
1518
+        cx.notify();
1519
+    }
1520
+
1521
+    fn begin_entitlement_edit(
1522
+        &mut self,
1523
+        row: usize,
1524
+        field: EntitlementField,
1525
+        _: &ClickEvent,
1526
+        window: &mut Window,
1527
+        cx: &mut Context<Self>,
1528
+    ) {
1529
+        window.focus(&self.focus_handle, cx);
1530
+        let SettingsWindowRequest::AppSettings { app, team_id, .. } = &self.request else {
1531
+            return;
1532
+        };
1533
+        let entitlements = app.effective_entitlements(team_id);
1534
+        let Some(entitlement) = entitlements.get(row) else {
1535
+            return;
1536
+        };
1537
+        self.selected_entitlement = Some(row);
1538
+        self.entitlement_type_picker_open = false;
1539
+        if field == EntitlementField::ValueType {
1540
+            self.entitlement_type_picker_open = true;
1541
+            self.entitlement_edit = Some(EntitlementEdit::Type {
1542
+                row,
1543
+                selected: entitlement.value.type_label().into(),
1544
+            });
1545
+            cx.notify();
1546
+            return;
1547
+        }
1548
+
1549
+        if field == EntitlementField::Value {
1550
+            match &entitlement.value {
1551
+                EntitlementValue::Boolean(value) => {
1552
+                    self.entitlement_edit = Some(EntitlementEdit::Boolean { row, value: *value });
1553
+                    cx.notify();
1554
+                    return;
1555
+                }
1556
+                EntitlementValue::Array(_) => {
1557
+                    let items = entitlement
1558
+                        .value
1559
+                        .array_edit_values()
1560
+                        .into_iter()
1561
+                        .map(|value| cx.new(|cx| EditLine::new(value, cx)))
1562
+                        .collect::<Vec<_>>();
1563
+                    self.entitlement_edit = Some(EntitlementEdit::Array {
1564
+                        row,
1565
+                        items: items.clone(),
1566
+                    });
1567
+                    if let Some(input) = items.first() {
1568
+                        input.update(cx, |input, cx| input.focus(window, cx));
1569
+                    }
1570
+                    cx.notify();
1571
+                    return;
1572
+                }
1573
+                _ => {}
1574
+            }
1575
+        }
1576
+
1577
+        let value = entitlement_field_value(entitlement, field).clone();
1578
+        let input = cx.new(|cx| EditLine::new(value, cx));
1579
+        self.entitlement_edit = Some(EntitlementEdit::Text {
1580
+            row,
1581
+            field,
1582
+            input: input.clone(),
1583
+        });
1584
+        input.update(cx, |input, cx| input.focus(window, cx));
1585
+        cx.notify();
1586
+    }
1587
+
1588
+    fn toggle_boolean_entitlement_value(
1589
+        &mut self,
1590
+        _: &ClickEvent,
1591
+        window: &mut Window,
1592
+        cx: &mut Context<Self>,
1593
+    ) {
1594
+        cx.stop_propagation();
1595
+        window.focus(&self.focus_handle, cx);
1596
+        let Some(EntitlementEdit::Boolean { value: edited, .. }) = &mut self.entitlement_edit
1597
+        else {
1598
+            return;
1599
+        };
1600
+        *edited = !*edited;
1601
+        cx.notify();
1602
+    }
1603
+
1604
+    fn add_entitlement_array_item(
1605
+        &mut self,
1606
+        _: &ClickEvent,
1607
+        window: &mut Window,
1608
+        cx: &mut Context<Self>,
1609
+    ) {
1610
+        cx.stop_propagation();
1611
+        window.focus(&self.focus_handle, cx);
1612
+        let Some(EntitlementEdit::Array { items, .. }) = &mut self.entitlement_edit else {
1613
+            return;
1614
+        };
1615
+        let input = cx.new(|cx| EditLine::new("", cx));
1616
+        items.push(input.clone());
1617
+        input.update(cx, |input, cx| input.focus(window, cx));
1618
+        cx.notify();
1619
+    }
1620
+
1621
+    fn remove_entitlement_array_item(
1622
+        &mut self,
1623
+        index: usize,
1624
+        _: &ClickEvent,
1625
+        window: &mut Window,
1626
+        cx: &mut Context<Self>,
1627
+    ) {
1628
+        cx.stop_propagation();
1629
+        window.focus(&self.focus_handle, cx);
1630
+        let Some(EntitlementEdit::Array { items, .. }) = &mut self.entitlement_edit else {
1631
+            return;
1632
+        };
1633
+        if index < items.len() {
1634
+            items.remove(index);
1635
+        }
1636
+        cx.notify();
1637
+    }
1638
+
1639
+    fn toggle_entitlement_type_picker(
1640
+        &mut self,
1641
+        _: &ClickEvent,
1642
+        window: &mut Window,
1643
+        cx: &mut Context<Self>,
1644
+    ) {
1645
+        cx.stop_propagation();
1646
+        window.focus(&self.focus_handle, cx);
1647
+        if matches!(self.entitlement_edit, Some(EntitlementEdit::Type { .. })) {
1648
+            self.entitlement_type_picker_open = !self.entitlement_type_picker_open;
1649
+        }
1650
+        cx.notify();
1651
+    }
1652
+
1653
+    fn select_entitlement_type(
1654
+        &mut self,
1655
+        label: &'static str,
1656
+        _: &ClickEvent,
1657
+        window: &mut Window,
1658
+        cx: &mut Context<Self>,
1659
+    ) {
1660
+        cx.stop_propagation();
1661
+        window.focus(&self.focus_handle, cx);
1662
+        let Some(EntitlementEdit::Type { selected, .. }) = &mut self.entitlement_edit else {
1663
+            return;
1664
+        };
1665
+        *selected = label.into();
1666
+        self.entitlement_type_picker_open = false;
1667
+        cx.notify();
1668
+    }
1669
+
1670
+    fn save_entitlement_edit_from_button(
1671
+        &mut self,
1672
+        _: &ClickEvent,
1673
+        window: &mut Window,
1674
+        cx: &mut Context<Self>,
1675
+    ) {
1676
+        cx.stop_propagation();
1677
+        window.focus(&self.focus_handle, cx);
1678
+        self.save_entitlement_edit(cx);
995 1679
     }
996 1680
 
997
-fn settings_app_id_row(app_id: &AppIdOption, selected: bool) -> gpui::Div {
998
-    div()
999
-        .h(px(COMBO_ITEM_HEIGHT))
1000
-        .px_3()
1001
-        .rounded_md()
1002
-        .border_1()
1003
-        .border_color(if selected {
1004
-            rgb(0x0f6f7a)
1005
-        } else {
1006
-            rgb(0xd8e0df)
1007
-        })
1008
-        .bg(if selected {
1009
-            rgb(0xf0fbfa)
1681
+    fn cancel_entitlement_edit(
1682
+        &mut self,
1683
+        _: &ClickEvent,
1684
+        window: &mut Window,
1685
+        cx: &mut Context<Self>,
1686
+    ) {
1687
+        cx.stop_propagation();
1688
+        window.focus(&self.focus_handle, cx);
1689
+        self.entitlement_type_picker_open = false;
1690
+        self.entitlement_edit = None;
1691
+        cx.notify();
1692
+    }
1693
+
1694
+    fn handle_entitlement_editor_key(
1695
+        &mut self,
1696
+        event: &KeyDownEvent,
1697
+        window: &mut Window,
1698
+        cx: &mut Context<Self>,
1699
+    ) {
1700
+        match event.keystroke.key.as_str() {
1701
+            "enter" => {
1702
+                cx.stop_propagation();
1703
+                window.prevent_default();
1704
+                self.save_entitlement_edit(cx);
1705
+            }
1706
+            "escape" => {
1707
+                cx.stop_propagation();
1708
+                window.prevent_default();
1709
+                self.entitlement_type_picker_open = false;
1710
+                self.entitlement_edit = None;
1711
+                cx.notify();
1712
+            }
1713
+            _ => {}
1714
+        }
1715
+    }
1716
+
1717
+    fn save_entitlement_edit(&mut self, cx: &mut Context<Self>) {
1718
+        let Some(edit) = self.entitlement_edit.take() else {
1719
+            return;
1720
+        };
1721
+        self.entitlement_type_picker_open = false;
1722
+        let SettingsWindowRequest::AppSettings {
1723
+            parent,
1724
+            app_index,
1725
+            app,
1726
+            team_id,
1727
+            ..
1728
+        } = &mut self.request
1729
+        else {
1730
+            cx.notify();
1731
+            return;
1732
+        };
1733
+
1734
+        let row = edit.row();
1735
+        let mut entitlements = app.effective_entitlements(team_id);
1736
+        let Some(entitlement) = entitlements.get_mut(row) else {
1737
+            cx.notify();
1738
+            return;
1739
+        };
1740
+
1741
+        match edit {
1742
+            EntitlementEdit::Text { field, input, .. } => {
1743
+                let value = input.read(cx).value();
1744
+                match field {
1745
+                    EntitlementField::Key => entitlement.key = value.into(),
1746
+                    EntitlementField::ValueType => {
1747
+                        entitlement.value = entitlement.value.with_type_label(value.as_ref())
1748
+                    }
1749
+                    EntitlementField::Value => {
1750
+                        entitlement.value = entitlement.value.with_edit_text(value.as_ref())
1751
+                    }
1752
+                }
1753
+            }
1754
+            EntitlementEdit::Boolean { value, .. } => {
1755
+                entitlement.value = EntitlementValue::Boolean(value);
1756
+            }
1757
+            EntitlementEdit::Array { items, .. } => {
1758
+                entitlement.value = EntitlementValue::string_array(
1759
+                    items
1760
+                        .iter()
1761
+                        .map(|input| input.read(cx).value().to_string())
1762
+                        .collect(),
1763
+                );
1764
+            }
1765
+            EntitlementEdit::Type { selected, .. } => {
1766
+                entitlement.value = entitlement.value.with_type_label(selected.as_ref());
1767
+            }
1768
+        }
1769
+
1770
+        app.entitlement_overrides = Some(entitlements);
1771
+        self.selected_entitlement = Some(row);
1772
+        let app_index = *app_index;
1773
+        let updated = app.clone();
1774
+        let _ = parent.update(cx, |view, cx| {
1775
+            view.replace_app_from_settings(app_index, updated, cx);
1776
+        });
1777
+        cx.notify();
1778
+    }
1779
+}
1780
+
1781
+impl Render for SettingsWindow {
1782
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1783
+        if self.adi_operation.is_some() {
1784
+            self.spinner_turns = (self.spinner_turns + 0.035) % 1.;
1785
+            window.request_animation_frame();
1010 1786
         } else {
1011
-            rgb(0xffffff)
1012
-        })
1013
-        .cursor_pointer()
1014
-        .hover(|style| style.bg(rgb(0xf6f9f9)))
1015
-        .flex()
1016
-        .items_center()
1017
-        .child(combo_item_content(
1018
-            app_id.identifier.clone(),
1019
-            app_id.name.clone(),
1020
-            app_id.kind.clone(),
1021
-        ))
1787
+            self.spinner_turns = 0.;
1022 1788
         }
1023 1789
 
1024
-fn settings_backend_combo_with_popover(
1025
-    trigger: impl IntoElement,
1026
-    open: bool,
1027
-    popover: impl IntoElement,
1028
-) -> gpui::Div {
1029
-    div().relative().child(trigger).when(open, |this| {
1030
-        this.child(
1031
-            div().absolute().top_0().left_0().w(px(0.)).h(px(0.)).child(
1032
-                deferred(
1033
-                    anchored()
1034
-                        .snap_to_window_with_margin(px(8.))
1035
-                        .position_mode(AnchoredPositionMode::Local)
1036
-                        .position(point(px(0.), px(ADI_BACKEND_COMBO_HEIGHT + 8.)))
1037
-                        .child(
1038
-                            div()
1039
-                                .w(px(ADI_BACKEND_POPOVER_WIDTH))
1040
-                                .max_h(px(ADI_BACKEND_POPOVER_MAX_HEIGHT))
1041
-                                .rounded_md()
1042
-                                .border_1()
1043
-                                .border_color(rgb(0xd8e0df))
1044
-                                .bg(rgb(0xf8fbfa))
1045
-                                .id("adi-backend-popover-scroll")
1046
-                                .overflow_y_scroll()
1047
-                                .scrollbar_width(px(8.))
1048
-                                .occlude()
1049
-                                .child(popover),
1790
+        let team_picker_open = self.team_picker_open;
1791
+        let app_id_picker_open = self.app_id_picker_open;
1792
+        let adi_operation = self.adi_operation.clone();
1793
+        let adi_busy = adi_operation.is_some();
1794
+        let adi_backend_picker_open = self.adi_backend_picker_open && !adi_busy;
1795
+        let spinner_turns = self.spinner_turns;
1796
+        let machine_identity_edit = self.machine_identity_edit.clone();
1797
+        let app_detail_edit = self.app_detail_edit.clone();
1798
+        let selected_entitlement = self.selected_entitlement;
1799
+        let entitlement_edit = self.entitlement_edit.clone();
1800
+        let entitlement_type_picker_open = self.entitlement_type_picker_open;
1801
+
1802
+        match &mut self.request {
1803
+            SettingsWindowRequest::DeveloperLogin { .. } => developer_login_view::render(
1804
+                &self.focus_handle,
1805
+                &self.scroll_handle,
1806
+                &self.developer_login,
1807
+                cx,
1050 1808
             ),
1051
-                )
1052
-                .with_priority(10),
1809
+            SettingsWindowRequest::Team {
1810
+                teams,
1811
+                selected_team,
1812
+                auto_app_id,
1813
+                selected_app_id,
1814
+                ..
1815
+            } => developer_view::render(
1816
+                &self.focus_handle,
1817
+                &self.scroll_handle,
1818
+                teams,
1819
+                *selected_team,
1820
+                *auto_app_id,
1821
+                *selected_app_id,
1822
+                team_picker_open,
1823
+                app_id_picker_open,
1824
+                cx,
1053 1825
             ),
1054
-        )
1055
-    })
1056
-}
1057
-
1058
-fn settings_adi_backend_combo_button(backend: &AdiBackendOption, open: bool) -> gpui::Div {
1059
-    div()
1060
-        .h(px(ADI_BACKEND_COMBO_HEIGHT))
1061
-        .p_3()
1062
-        .rounded_md()
1063
-        .border_1()
1064
-        .border_color(if open { rgb(0x0f6f7a) } else { rgb(0xcfd8d6) })
1065
-        .bg(rgb(0xffffff))
1066
-        .cursor_pointer()
1067
-        .hover(|style| style.bg(rgb(0xf6f9f9)))
1068
-        .flex()
1069
-        .items_center()
1070
-        .gap_3()
1071
-        .child(status_dot(&backend.status))
1072
-        .child(
1073
-            div()
1074
-                .min_w_0()
1075
-                .flex()
1076
-                .flex_1()
1077
-                .flex_col()
1078
-                .gap_1()
1079
-                .child(
1080
-                    div()
1081
-                        .text_sm()
1082
-                        .font_weight(FontWeight::SEMIBOLD)
1083
-                        .text_color(rgb(0x24333a))
1084
-                        .text_ellipsis()
1085
-                        .child(backend.name.clone()),
1086
-                )
1087
-                .child(
1088
-                    div()
1089
-                        .text_xs()
1090
-                        .text_color(rgb(0x6a7a81))
1091
-                        .text_ellipsis()
1092
-                        .child(backend.detail.clone()),
1826
+            SettingsWindowRequest::AppSettings {
1827
+                app,
1828
+                enabled_patches,
1829
+                team_id,
1830
+                ..
1831
+            } => app_view::render(
1832
+                &self.focus_handle,
1833
+                &self.scroll_handle,
1834
+                app,
1835
+                enabled_patches,
1836
+                team_id,
1837
+                app_detail_edit.as_ref(),
1838
+                selected_entitlement,
1839
+                entitlement_edit.as_ref(),
1840
+                entitlement_type_picker_open,
1841
+                cx,
1093 1842
             ),
1094
-        )
1095
-        .child(status_badge(backend.status.clone(), false))
1096
-        .child(chevron(open))
1843
+            SettingsWindowRequest::AdiSettings {
1844
+                backends,
1845
+                selected_backend,
1846
+                machine_identity,
1847
+                android_device_identity,
1848
+                android_adi_identifier: _,
1849
+                ..
1850
+            } => adi_view::render(
1851
+                &self.focus_handle,
1852
+                &self.scroll_handle,
1853
+                backends,
1854
+                *selected_backend,
1855
+                machine_identity,
1856
+                android_device_identity,
1857
+                adi_backend_picker_open,
1858
+                adi_operation.as_ref(),
1859
+                spinner_turns,
1860
+                machine_identity_edit.as_ref(),
1861
+                cx,
1862
+            ),
1863
+        }
1864
+    }
1097 1865
 }
1098 1866
 
1099
-fn settings_adi_backend_option(backend: &AdiBackendOption, selected: bool) -> gpui::Div {
1100
-    div()
1101
-        .min_h(px(56.))
1102
-        .p_2()
1103
-        .rounded_md()
1104
-        .border_1()
1105
-        .border_color(if selected {
1106
-            rgb(0x0f6f7a)
1107
-        } else {
1108
-            rgb(0xd8e0df)
1109
-        })
1110
-        .bg(if selected {
1111
-            rgb(0xf0fbfa)
1112
-        } else {
1113
-            rgb(0xffffff)
1867
+pub(crate) fn show_or_open_settings_window(
1868
+    handle: Option<WindowHandle<SettingsWindow>>,
1869
+    request: SettingsWindowRequest,
1870
+    width: f32,
1871
+    height: f32,
1872
+    cx: &mut App,
1873
+) -> WindowHandle<SettingsWindow> {
1874
+    if let Some(handle) = handle {
1875
+        if handle
1876
+            .update(cx, |settings, window, cx| {
1877
+                settings.show_request(request.clone(), window, cx);
1114 1878
             })
1115
-        .cursor_pointer()
1116
-        .hover(|style| style.bg(rgb(0xf6f9f9)))
1117
-        .flex()
1118
-        .items_center()
1119
-        .gap_2()
1120
-        .child(status_dot(&backend.status))
1121
-        .child(
1122
-            div()
1123
-                .min_w_0()
1124
-                .flex()
1125
-                .flex_1()
1126
-                .flex_col()
1127
-                .gap_0p5()
1128
-                .child(
1129
-                    div()
1130
-                        .text_sm()
1131
-                        .font_weight(FontWeight::SEMIBOLD)
1132
-                        .text_color(rgb(0x24333a))
1133
-                        .text_ellipsis()
1134
-                        .child(backend.name.clone()),
1135
-                )
1136
-                .child(
1137
-                    div()
1138
-                        .text_xs()
1139
-                        .text_color(rgb(0x6a7a81))
1140
-                        .text_ellipsis()
1141
-                        .child(backend.detail.clone()),
1142
-                ),
1143
-        )
1144
-        .child(status_badge(backend.status.clone(), selected))
1879
+            .is_ok()
1880
+        {
1881
+            return handle;
1882
+        }
1145 1883
     }
1146 1884
 
1147
-fn adi_backend_status_section(
1148
-    backend: &AdiBackendOption,
1149
-    cx: &mut Context<SettingsWindow>,
1150
-) -> gpui::Div {
1151
-    let color = backend_status_color(&backend.status);
1885
+    open_settings_window(request, width, height, cx)
1886
+}
1152 1887
 
1153
-    div()
1154
-        .p_3()
1155
-        .rounded_md()
1156
-        .border_1()
1157
-        .border_color(rgb(0xd8e0df))
1158
-        .bg(rgb(0xffffff))
1159
-        .flex()
1160
-        .items_start()
1161
-        .gap_3()
1162
-        .child(
1163
-            div()
1164
-                .flex_none()
1165
-                .w(px(4.))
1166
-                .h_full()
1167
-                .min_h(px(76.))
1168
-                .rounded_full()
1169
-                .bg(rgb(color)),
1170
-        )
1171
-        .child(
1172
-            div()
1173
-                .min_w_0()
1174
-                .flex()
1175
-                .flex_1()
1176
-                .flex_col()
1177
-                .gap_2()
1178
-                .child(
1179
-                    div()
1180
-                        .min_w_0()
1181
-                        .flex()
1182
-                        .flex_wrap()
1183
-                        .items_center()
1184
-                        .justify_between()
1185
-                        .gap_2()
1186
-                        .child(
1187
-                            div()
1188
-                                .min_w_0()
1189
-                                .flex()
1190
-                                .items_center()
1191
-                                .gap_2()
1192
-                                .child(status_dot(&backend.status))
1193
-                                .child(
1194
-                                    div()
1195
-                                        .text_sm()
1196
-                                        .font_weight(FontWeight::SEMIBOLD)
1197
-                                        .text_color(rgb(0x24333a))
1198
-                                        .child("Status"),
1199
-                                ),
1200
-                        )
1201
-                        .child(status_badge(backend.status.clone(), false)),
1202
-                )
1203
-                .child(
1204
-                    div()
1205
-                        .min_w_0()
1206
-                        .w_full()
1207
-                        .text_xs()
1208
-                        .line_height(px(18.))
1209
-                        .text_color(rgb(0x6a7a81))
1210
-                        .whitespace_normal()
1211
-                        .child(backend.information.clone()),
1212
-                )
1213
-                .when(backend.repair_action.is_some(), |this| {
1214
-                    let label = backend
1215
-                        .repair_action
1216
-                        .clone()
1217
-                        .unwrap_or_else(|| "Fix Issues".into());
1218
-                    this.child(
1219
-                        div()
1220
-                            .id("repair-adi-backend")
1221
-                            .h_8()
1222
-                            .px_3()
1223
-                            .rounded_md()
1224
-                            .bg(rgb(color))
1225
-                            .text_color(rgb(0xffffff))
1226
-                            .text_xs()
1227
-                            .font_weight(FontWeight::SEMIBOLD)
1228
-                            .flex()
1229
-                            .items_center()
1230
-                            .justify_center()
1231
-                            .cursor_pointer()
1232
-                            .hover(|style| style.opacity(0.88))
1233
-                            .on_click(cx.listener(SettingsWindow::repair_adi_backend))
1234
-                            .child(label),
1235
-                    )
1236
-                }),
1888
+fn open_settings_window(
1889
+    request: SettingsWindowRequest,
1890
+    width: f32,
1891
+    height: f32,
1892
+    cx: &mut App,
1893
+) -> WindowHandle<SettingsWindow> {
1894
+    let window_size = size(px(width), px(height));
1895
+    let bounds = Bounds::centered(None, window_size, cx);
1896
+    let title = request.title();
1897
+
1898
+    cx.open_window(
1899
+        WindowOptions {
1900
+            window_bounds: Some(WindowBounds::Windowed(bounds)),
1901
+            window_min_size: Some(window_size),
1902
+            is_resizable: true,
1903
+            is_minimizable: false,
1904
+            kind: WindowKind::Floating,
1905
+            ..Default::default()
1906
+        },
1907
+        |window, cx| {
1908
+            window.set_window_title(title);
1909
+            cx.new(|cx| SettingsWindow::new(request, window, cx))
1910
+        },
1237 1911
     )
1912
+    .expect("failed to open settings window")
1238 1913
 }
1239 1914
 
1240
-fn machine_identity_section(
1241
-    identity: &MachineIdentity,
1242
-    editable: bool,
1243
-    cx: &mut Context<SettingsWindow>,
1244
-) -> gpui::Div {
1245
-    div()
1246
-        .p_3()
1247
-        .rounded_md()
1248
-        .border_1()
1249
-        .border_color(rgb(0xd8e0df))
1250
-        .bg(rgb(0xffffff))
1251
-        .flex()
1252
-        .flex_col()
1253
-        .gap_3()
1254
-        .child(
1255
-            div()
1256
-                .flex()
1257
-                .items_center()
1258
-                .gap_2()
1259
-                .child(
1260
-                    div()
1261
-                        .text_sm()
1262
-                        .font_weight(FontWeight::SEMIBOLD)
1263
-                        .text_color(rgb(0x24333a))
1264
-                        .child("Machine Identity"),
1915
+fn open_app_data_folder() -> Result<(), String> {
1916
+    let data_dir = app_data_dir()
1917
+        .ok_or_else(|| "The application data folder is not available.".to_string())?;
1918
+    fs::create_dir_all(&data_dir).map_err(|error| {
1919
+        format!(
1920
+            "Failed to create application data folder at {}: {error}",
1921
+            data_dir.display()
1265 1922
         )
1266
-                .when(!editable, |this| this.child(read_only_badge())),
1923
+    })?;
1924
+
1925
+    let mut command = if cfg!(target_os = "macos") {
1926
+        Command::new("open")
1927
+    } else if cfg!(target_os = "windows") {
1928
+        Command::new("explorer")
1929
+    } else {
1930
+        Command::new("xdg-open")
1931
+    };
1932
+
1933
+    command.arg(&data_dir).spawn().map_err(|error| {
1934
+        format!(
1935
+            "Failed to open application data folder at {}: {error}",
1936
+            data_dir.display()
1267 1937
         )
1268
-        .child(machine_identity_row(
1269
-            "Machine name",
1270
-            identity.machine_name.clone(),
1271
-            editable,
1272
-            cx,
1273
-        ))
1274
-        .child(machine_identity_row(
1275
-            "OS name",
1276
-            identity.os_name.clone(),
1277
-            editable,
1278
-            cx,
1279
-        ))
1280
-        .child(machine_identity_row(
1281
-            "OS version",
1282
-            identity.os_version.clone(),
1283
-            editable,
1284
-            cx,
1285
-        ))
1286
-        .child(machine_identity_row(
1287
-            "Machine ID",
1288
-            identity.machine_id.clone(),
1289
-            editable,
1290
-            cx,
1291
-        ))
1938
+    })?;
1939
+    Ok(())
1292 1940
 }
1293 1941
 
1294
-fn machine_identity_row(
1295
-    label: &'static str,
1296
-    value: SharedString,
1297
-    editable: bool,
1298
-    cx: &mut Context<SettingsWindow>,
1942
+fn scroll_panel(
1943
+    scroll_id: &'static str,
1944
+    scroll_handle: &ScrollHandle,
1945
+    content: impl IntoElement,
1299 1946
 ) -> gpui::Div {
1947
+    let viewport_height = scroll_handle.bounds().size.height.as_f32();
1948
+    let max_scroll = scroll_handle.max_offset().y.as_f32();
1949
+    let is_scrollable = viewport_height > 0. && max_scroll > 0.5;
1950
+    let scroll_offset = (-scroll_handle.offset().y.as_f32()).clamp(0., max_scroll);
1951
+    let content_height = viewport_height + max_scroll;
1952
+    let thumb_height = if is_scrollable {
1953
+        ((viewport_height / content_height) * viewport_height).clamp(36., viewport_height)
1954
+    } else {
1955
+        0.
1956
+    };
1957
+    let thumb_top = if is_scrollable && max_scroll > 0. {
1958
+        (scroll_offset / max_scroll) * (viewport_height - thumb_height)
1959
+    } else {
1960
+        0.
1961
+    };
1962
+
1300 1963
     div()
1301
-        .min_w_0()
1964
+        .min_h_0()
1965
+        .flex_1()
1302 1966
         .flex()
1303
-        .items_center()
1304
-        .gap_3()
1305
-        .child(
1306
-            div()
1307
-                .w(px(104.))
1308
-                .flex_none()
1309
-                .text_xs()
1310
-                .text_color(rgb(0x7b8a90))
1311
-                .child(label),
1312
-        )
1967
+        .gap_2()
1313 1968
         .child(
1314 1969
             div()
1970
+                .id(scroll_id)
1315 1971
                 .min_w_0()
1972
+                .h_full()
1316 1973
                 .flex_1()
1317
-                .text_sm()
1318
-                .text_color(rgb(0x405057))
1319
-                .text_ellipsis()
1320
-                .child(value),
1974
+                .overflow_y_scroll()
1975
+                .scrollbar_width(px(1.))
1976
+                .track_scroll(scroll_handle)
1977
+                .child(content),
1321 1978
         )
1322
-        .when(editable, |this| {
1979
+        .when(is_scrollable, |this| {
1323 1980
             this.child(
1324
-                div()
1325
-                    .id(("edit-machine-identity", machine_identity_id(label)))
1326
-                    .flex_none()
1327
-                    .w_6()
1328
-                    .h_6()
1329
-                    .rounded_md()
1330
-                    .bg(rgb(0xebf1f0))
1331
-                    .text_color(rgb(0x53666d))
1332
-                    .flex()
1333
-                    .items_center()
1334
-                    .justify_center()
1335
-                    .cursor_pointer()
1336
-                    .hover(|style| style.bg(rgb(0xdfe8e6)))
1337
-                    .on_click(cx.listener(SettingsWindow::edit_machine_identity))
1338
-                    .child(lucide_icon("icons/pencil.svg")),
1339
-            )
1340
-        })
1341
-}
1342
-
1343
-fn machine_identity_id(label: &'static str) -> usize {
1344
-    match label {
1345
-        "Machine name" => 0,
1346
-        "OS name" => 1,
1347
-        "OS version" => 2,
1348
-        "Machine ID" => 3,
1349
-        _ => 99,
1350
-    }
1351
-}
1352
-
1353
-fn status_badge(label: SharedString, selected: bool) -> gpui::Div {
1354
-    let color = backend_status_color(&label);
1355
-
1356 1981
                 div()
1357 1982
                     .flex_none()
1358
-        .px_2()
1359
-        .h_6()
1983
+                    .w(px(6.))
1984
+                    .h_full()
1360 1985
                     .rounded_full()
1361
-        .bg(if selected {
1362
-            rgb(status_badge_selected_bg(&label))
1363
-        } else {
1364
-            rgb(status_badge_bg(&label))
1365
-        })
1366
-        .text_color(rgb(color))
1367
-        .text_xs()
1368
-        .font_weight(FontWeight::SEMIBOLD)
1986
+                    .bg(rgb(0xdde5e4))
1369 1987
                     .flex()
1370
-        .items_center()
1371
-        .justify_center()
1372
-        .child(label)
1373
-}
1374
-
1375
-fn status_dot(status: &SharedString) -> gpui::Div {
1988
+                    .flex_col()
1989
+                    .child(div().flex_none().h(px(thumb_top)))
1990
+                    .child(
1376 1991
                         div()
1377 1992
                             .flex_none()
1378
-        .w_2()
1379
-        .h_2()
1993
+                            .w_full()
1994
+                            .h(px(thumb_height))
1380 1995
                             .rounded_full()
1381
-        .bg(rgb(backend_status_color(status)))
1382
-}
1383
-
1384
-fn backend_status_color(status: &SharedString) -> u32 {
1385
-    match status.to_string().as_str() {
1386
-        "Ready" => 0x1d6b45,
1387
-        "Needs setup" => 0x9a6a14,
1388
-        "Offline" => 0x9a302b,
1389
-        _ => 0x53666d,
1390
-    }
1391
-}
1392
-
1393
-fn status_badge_bg(status: &SharedString) -> u32 {
1394
-    match status.to_string().as_str() {
1395
-        "Ready" => 0xe5f3ec,
1396
-        "Needs setup" => 0xf6edda,
1397
-        "Offline" => 0xf4e4e2,
1398
-        _ => 0xebf1f0,
1399
-    }
1400
-}
1401
-
1402
-fn status_badge_selected_bg(status: &SharedString) -> u32 {
1403
-    match status.to_string().as_str() {
1404
-        "Ready" => 0xd3eadc,
1405
-        "Needs setup" => 0xeadbb8,
1406
-        "Offline" => 0xebcecb,
1407
-        _ => 0xd7efec,
1408
-    }
1996
+                            .bg(rgb(0x789094)),
1997
+                    )
1998
+                    .child(div().flex_1()),
1999
+            )
2000
+        })
1409 2001
 }
1410 2002
 
1411
-fn read_only_badge() -> gpui::Div {
2003
+fn settings_window_shell() -> gpui::Div {
1412 2004
     div()
1413
-        .px_2()
1414
-        .h_5()
1415
-        .rounded_full()
1416
-        .bg(rgb(0xf0f2f2))
1417
-        .text_color(rgb(0x718086))
1418
-        .text_xs()
1419
-        .font_weight(FontWeight::SEMIBOLD)
2005
+        .size_full()
2006
+        .p_5()
1420 2007
         .flex()
1421
-        .items_center()
1422
-        .justify_center()
1423
-        .child("Read-only")
2008
+        .flex_col()
2009
+        .bg(rgb(0xf4f6f4))
2010
+        .text_color(rgb(0x263238))
2011
+        .font_family(".SystemUIFont")
1424 2012
 }
1425 2013
 
1426
-fn app_icon_editor(app: &AppOption, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1427
-    let initial = app
1428
-        .name
1429
-        .to_string()
1430
-        .chars()
1431
-        .next()
1432
-        .map(|c| c.to_string())
1433
-        .unwrap_or_else(|| "A".to_string());
1434
-
2014
+fn settings_window_header(title: &'static str, detail: &'static str) -> gpui::Div {
1435 2015
     div()
1436
-        .flex_none()
1437
-        .w(px(84.))
1438
-        .h(px(84.))
1439
-        .rounded_md()
1440
-        .border_1()
1441
-        .border_color(rgb(0xcfd8d6))
1442
-        .bg(rgb(0x173f45))
1443
-        .text_color(rgb(0xffffff))
1444
-        .cursor_pointer()
1445
-        .hover(|style| style.bg(rgb(0x20545c)))
1446 2016
         .flex()
1447 2017
         .flex_col()
1448
-        .items_center()
1449
-        .justify_center()
1450
-        .gap_1()
1451
-        .id("app-icon-editor")
1452
-        .on_click(cx.listener(SettingsWindow::edit_app_icon))
2018
+        .gap_0p5()
1453 2019
         .child(
1454 2020
             div()
1455
-                .text_2xl()
2021
+                .text_sm()
1456 2022
                 .font_weight(FontWeight::BOLD)
1457
-                .child(initial),
2023
+                .text_color(rgb(0x24333a))
2024
+                .child(title),
1458 2025
         )
1459
-        .child(div().text_xs().child("Edit icon"))
2026
+        .child(div().text_xs().text_color(rgb(0x6a7a81)).child(detail))
1460 2027
 }
1461 2028
 
1462
-fn app_detail_row(
1463
-    label: &'static str,
1464
-    value: String,
1465
-    editable: bool,
1466
-    cx: &mut Context<SettingsWindow>,
2029
+fn settings_window_header_with_action(
2030
+    header: impl IntoElement,
2031
+    action: impl IntoElement,
1467 2032
 ) -> gpui::Div {
1468 2033
     div()
1469
-        .min_w_0()
1470 2034
         .flex()
1471
-        .items_center()
2035
+        .items_start()
2036
+        .justify_between()
1472 2037
         .gap_3()
1473
-        .child(
1474
-            div()
1475
-                .w(px(96.))
1476
-                .flex_none()
1477
-                .text_xs()
1478
-                .text_color(rgb(0x7b8a90))
1479
-                .child(label),
1480
-        )
1481
-        .child(
1482
-            div()
1483
-                .min_w_0()
1484
-                .flex_1()
1485
-                .text_sm()
1486
-                .text_color(rgb(0x405057))
1487
-                .text_ellipsis()
1488
-                .child(value),
1489
-        )
1490
-        .when(editable, |this| this.child(app_detail_controls(label, cx)))
1491
-}
1492
-
1493
-fn app_detail_controls(label: &'static str, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1494
-    div()
1495
-        .flex_none()
1496
-        .w(px(84.))
1497
-        .flex()
1498
-        .items_center()
1499
-        .justify_end()
1500
-        .gap_1()
1501
-        .child(app_detail_revert_slot(label, false, cx))
1502
-        .child(edit_button(label, cx))
2038
+        .child(header)
2039
+        .child(action)
1503 2040
 }
1504 2041
 
1505
-fn edit_button(label: &'static str, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
2042
+fn open_data_folder_button(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1506 2043
     div()
2044
+        .id("open-data-folder")
1507 2045
         .flex_none()
1508
-        .w_6()
1509
-        .h_6()
2046
+        .h_8()
2047
+        .px_3()
1510 2048
         .rounded_md()
1511 2049
         .bg(rgb(0xebf1f0))
1512 2050
         .text_color(rgb(0x53666d))
2051
+        .text_xs()
2052
+        .font_weight(FontWeight::SEMIBOLD)
1513 2053
         .flex()
1514 2054
         .items_center()
1515 2055
         .justify_center()
2056
+        .gap_2()
1516 2057
         .cursor_pointer()
1517 2058
         .hover(|style| style.bg(rgb(0xdfe8e6)))
1518
-        .id(("edit-app-detail", app_detail_id(label)))
1519
-        .on_click(cx.listener(SettingsWindow::edit_app_detail))
1520
-        .child(lucide_icon("icons/pencil.svg"))
2059
+        .on_click(cx.listener(SettingsWindow::open_data_folder))
2060
+        .child(lucide_icon("icons/folder-open.svg"))
2061
+        .child("Open Data Folder")
1521 2062
 }
1522 2063
 
1523
-fn app_detail_revert_slot(
1524
-    label: &'static str,
1525
-    changed: bool,
1526
-    cx: &mut Context<SettingsWindow>,
1527
-) -> impl IntoElement {
2064
+fn settings_label(label: &'static str) -> gpui::Div {
1528 2065
     div()
1529
-        .flex_none()
1530
-        .w(px(52.))
1531
-        .h_6()
1532
-        .rounded_md()
1533
-        .bg(rgb(0xebf1f0))
1534
-        .text_color(rgb(0x53666d))
1535 2066
         .text_xs()
1536 2067
         .font_weight(FontWeight::SEMIBOLD)
1537
-        .flex()
1538
-        .items_center()
1539
-        .justify_center()
1540
-        .id(("revert-app-detail", app_detail_id(label)))
1541
-        .when(!changed, |this| this.opacity(0.))
1542
-        .when(changed, |this| {
1543
-            this.cursor_pointer()
1544
-                .hover(|style| style.bg(rgb(0xdfe8e6)))
1545
-                .on_click(cx.listener(SettingsWindow::revert_app_detail))
1546
-        })
1547
-        .child("Revert")
1548
-}
1549
-
1550
-fn app_detail_id(label: &'static str) -> usize {
1551
-    match label {
1552
-        "Name" => 0,
1553
-        "Bundle ID" => 1,
1554
-        "Version" => 2,
1555
-        "Build" => 3,
1556
-        "Executable" => 4,
1557
-        "Minimum OS" => 5,
1558
-        "Supported devices" => 6,
1559
-        _ => 99,
1560
-    }
1561
-}
1562
-
1563
-fn enabled_patch_summary(app: &AppOption, enabled_patches: &[bool]) -> String {
1564
-    let enabled = app
1565
-        .patches
1566
-        .iter()
1567
-        .enumerate()
1568
-        .filter_map(|(index, patch)| {
1569
-            enabled_patches
1570
-                .get(index)
1571
-                .copied()
1572
-                .filter(|enabled| *enabled)
1573
-                .map(|_| format!("{} ({})", patch.name, patch.detail))
1574
-        })
1575
-        .collect::<Vec<_>>();
1576
-
1577
-    if enabled.is_empty() {
1578
-        "No patch changes selected".to_string()
1579
-    } else {
1580
-        enabled.join(", ")
1581
-    }
2068
+        .text_color(rgb(0x66767c))
2069
+        .child(label)
1582 2070
 }
1583 2071
 
1584
-fn entitlements_table(app: &AppOption, cx: &mut Context<SettingsWindow>) -> gpui::Div {
2072
+fn machine_identity_editor(input: &Entity<EditLine>) -> gpui::Div {
1585 2073
     div()
1586
-        .flex()
1587
-        .flex_col()
2074
+        .min_w_0()
2075
+        .flex_1()
2076
+        .h_7()
2077
+        .px_2()
1588 2078
         .rounded_md()
1589 2079
         .border_1()
1590
-        .border_color(rgb(0xd8e0df))
1591
-        .bg(rgb(0xffffff))
1592
-        .overflow_hidden()
1593
-        .child(
1594
-            div()
1595
-                .px_3()
1596
-                .py_2()
1597
-                .border_b_1()
1598
-                .border_color(rgb(0xd8e0df))
1599
-                .flex()
1600
-                .items_center()
1601
-                .justify_start()
1602
-                .gap_2()
1603
-                .child(
1604
-                    div()
2080
+        .border_color(rgb(0x168291))
2081
+        .bg(rgb(0xf2fbfb))
1605 2082
         .text_sm()
1606
-                        .font_weight(FontWeight::SEMIBOLD)
1607 2083
         .text_color(rgb(0x24333a))
1608
-                        .child("Entitlements"),
1609
-                )
1610
-                .child(entitlements_actions(false, cx)),
1611
-        )
1612
-        .child(entitlement_header_row())
1613
-        .child(entitlement_row(
1614
-            "application-identifier",
1615
-            "String",
1616
-            format!("TEAMID1234.{}", app.bundle_id),
1617
-        ))
1618
-        .child(entitlement_row(
1619
-            "com.apple.developer.team-identifier",
1620
-            "String",
1621
-            "TEAMID1234".to_string(),
1622
-        ))
1623
-        .child(entitlement_row(
1624
-            "get-task-allow",
1625
-            "Boolean",
1626
-            "true".to_string(),
1627
-        ))
1628
-        .child(entitlement_row(
1629
-            "keychain-access-groups",
1630
-            "Array",
1631
-            format!("TEAMID1234.{}", app.bundle_id),
1632
-        ))
1633
-        .child(entitlement_row(
1634
-            "com.apple.security.application-groups",
1635
-            "Array",
1636
-            "None".to_string(),
1637
-        ))
1638
-}
1639
-
1640
-fn entitlements_actions(changed: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1641
-    div()
1642
-        .flex_none()
1643
-        .flex()
1644
-        .items_center()
1645
-        .gap_1()
1646
-        .child(entitlement_icon_button(
1647
-            "add-entitlement",
1648
-            "icons/plus.svg",
1649
-            cx.listener(SettingsWindow::add_entitlement),
1650
-        ))
1651
-        .child(entitlement_icon_button(
1652
-            "remove-entitlement",
1653
-            "icons/minus.svg",
1654
-            cx.listener(SettingsWindow::remove_entitlement),
1655
-        ))
1656
-        .child(entitlements_revert_slot(changed, cx))
1657
-}
1658
-
1659
-fn entitlement_icon_button(
1660
-    id: &'static str,
1661
-    icon: &'static str,
1662
-    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
1663
-) -> impl IntoElement {
1664
-    div()
1665
-        .flex_none()
1666
-        .w_6()
1667
-        .h_6()
1668
-        .rounded_md()
1669
-        .bg(rgb(0xebf1f0))
1670
-        .text_color(rgb(0x53666d))
1671 2084
         .flex()
1672 2085
         .items_center()
1673
-        .justify_center()
1674
-        .cursor_pointer()
1675
-        .hover(|style| style.bg(rgb(0xdfe8e6)))
1676
-        .id(id)
1677
-        .on_click(listener)
1678
-        .child(lucide_icon(icon))
2086
+        .overflow_hidden()
2087
+        .child(input.clone())
1679 2088
 }
1680 2089
 
1681
-fn entitlements_revert_slot(changed: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1682
-    div()
1683
-        .flex_none()
1684
-        .w(px(52.))
1685
-        .h_6()
1686
-        .rounded_md()
1687
-        .bg(rgb(0xebf1f0))
1688
-        .text_color(rgb(0x53666d))
1689
-        .text_xs()
1690
-        .font_weight(FontWeight::SEMIBOLD)
1691
-        .flex()
1692
-        .items_center()
1693
-        .justify_center()
1694
-        .id("revert-entitlements")
1695
-        .when(!changed, |this| this.opacity(0.))
1696
-        .when(changed, |this| {
1697
-            this.cursor_pointer()
1698
-                .hover(|style| style.bg(rgb(0xdfe8e6)))
1699
-                .on_click(cx.listener(SettingsWindow::revert_entitlements))
1700
-        })
1701
-        .child("Revert")
2090
+fn entitlement_revert_target(
2091
+    entitlement: &AppEntitlement,
2092
+    row: usize,
2093
+    defaults: &[AppEntitlement],
2094
+) -> Option<AppEntitlement> {
2095
+    defaults
2096
+        .iter()
2097
+        .find(|default| default.key == entitlement.key)
2098
+        .cloned()
2099
+        .or_else(|| defaults.get(row).cloned())
1702 2100
 }
1703 2101
 
1704
-fn entitlement_header_row() -> gpui::Div {
1705
-    div()
1706
-        .grid()
1707
-        .grid_cols(3)
1708
-        .px_3()
1709
-        .py_2()
1710
-        .bg(rgb(0xf6f9f9))
1711
-        .border_b_1()
1712
-        .border_color(rgb(0xd8e0df))
1713
-        .text_xs()
1714
-        .font_weight(FontWeight::SEMIBOLD)
1715
-        .text_color(rgb(0x66767c))
1716
-        .child("Key")
1717
-        .child("Type")
1718
-        .child("Value")
2102
+fn entitlement_field_value(entitlement: &AppEntitlement, field: EntitlementField) -> SharedString {
2103
+    match field {
2104
+        EntitlementField::Key => entitlement.key.clone(),
2105
+        EntitlementField::ValueType => entitlement.value.type_label().into(),
2106
+        EntitlementField::Value => entitlement.value.display_text(),
1719 2107
     }
1720
-
1721
-fn entitlement_row(key: &'static str, kind: &'static str, value: String) -> gpui::Div {
1722
-    div()
1723
-        .grid()
1724
-        .grid_cols(3)
1725
-        .px_3()
1726
-        .py_2()
1727
-        .border_b_1()
1728
-        .border_color(rgb(0xe8eeee))
1729
-        .text_xs()
1730
-        .text_color(rgb(0x405057))
1731
-        .child(div().min_w_0().text_ellipsis().child(key))
1732
-        .child(div().min_w_0().text_ellipsis().child(kind))
1733
-        .child(div().min_w_0().text_ellipsis().child(value))
1734 2108
 }
... ...
@@ -0,0 +1,770 @@
1
+use super::*;
2
+
3
+pub(super) fn render(
4
+    focus_handle: &FocusHandle,
5
+    scroll_handle: &ScrollHandle,
6
+    backends: &[AdiBackendOption],
7
+    selected_backend: usize,
8
+    machine_identity: &MachineIdentity,
9
+    android_device_identity: &MachineIdentity,
10
+    adi_backend_picker_open: bool,
11
+    adi_operation: Option<&AdiOperationState>,
12
+    spinner_turns: f32,
13
+    machine_identity_edit: Option<&MachineIdentityEdit>,
14
+    cx: &mut Context<SettingsWindow>,
15
+) -> gpui::Div {
16
+    let selected = selected_backend.min(backends.len().saturating_sub(1));
17
+    let backend = backends.get(selected);
18
+    let displayed_machine_identity: &MachineIdentity = match backend.map(|backend| backend.kind) {
19
+        Some(AdiBackendKind::AndroidCoreAdi) => android_device_identity,
20
+        Some(AdiBackendKind::SystemAdid) | Some(AdiBackendKind::WindowsCoreAdi) | None => {
21
+            machine_identity
22
+        }
23
+    };
24
+    let editable_identity = backend
25
+        .map(|backend| backend.editable_identity)
26
+        .unwrap_or(false);
27
+    let backend_controls_disabled = adi_operation.is_some();
28
+
29
+    let mut content = div().flex().flex_col().gap_4().child(
30
+        div()
31
+            .flex()
32
+            .flex_col()
33
+            .gap_2()
34
+            .child(
35
+                div()
36
+                    .text_xs()
37
+                    .font_weight(FontWeight::SEMIBOLD)
38
+                    .text_color(rgb(0x66767c))
39
+                    .child("ADI Backend"),
40
+            )
41
+            .when_some(backend, |this, backend| {
42
+                this.child(settings_backend_combo_with_popover(
43
+                    settings_adi_backend_combo_button(backend, adi_backend_picker_open)
44
+                        .id("adi-backend-combobox")
45
+                        .when(backend_controls_disabled, |this| this.opacity(0.55))
46
+                        .when(!backend_controls_disabled, |this| {
47
+                            this.on_click(cx.listener(SettingsWindow::toggle_adi_backend_picker))
48
+                        }),
49
+                    adi_backend_picker_open,
50
+                    dropdown_list((0..backends.len()).map(|index| {
51
+                        settings_adi_backend_option(&backends[index], index == selected)
52
+                            .id(("adi-backend-option", index))
53
+                            .on_click(cx.listener(move |this, event, window, cx| {
54
+                                this.select_adi_backend(index, event, window, cx)
55
+                            }))
56
+                    }))
57
+                    .id("adi-backend-options"),
58
+                ))
59
+            }),
60
+    );
61
+
62
+    if let Some(backend) = backend {
63
+        content = content.child(adi_backend_status_section(
64
+            backend,
65
+            adi_operation,
66
+            spinner_turns,
67
+            cx,
68
+        ));
69
+    }
70
+
71
+    content = content.child(
72
+        machine_identity_section(
73
+            displayed_machine_identity,
74
+            editable_identity && !backend_controls_disabled,
75
+            machine_identity_edit,
76
+            cx,
77
+        )
78
+        .when(backend_controls_disabled, |this| this.opacity(0.55)),
79
+    );
80
+
81
+    settings_window_shell()
82
+        .track_focus(focus_handle)
83
+        .gap_4()
84
+        .child(settings_window_header_with_action(
85
+            settings_window_header(
86
+                "Settings",
87
+                "Choose the ADI backend and review the machine identity it uses.",
88
+            ),
89
+            open_data_folder_button(cx),
90
+        ))
91
+        .child(scroll_panel("adi-settings-scroll", scroll_handle, content))
92
+}
93
+
94
+fn settings_backend_combo_with_popover(
95
+    trigger: impl IntoElement,
96
+    open: bool,
97
+    popover: impl IntoElement,
98
+) -> gpui::Div {
99
+    div().relative().child(trigger).when(open, |this| {
100
+        this.child(
101
+            div().absolute().top_0().left_0().w(px(0.)).h(px(0.)).child(
102
+                deferred(
103
+                    anchored()
104
+                        .snap_to_window_with_margin(px(8.))
105
+                        .position_mode(AnchoredPositionMode::Local)
106
+                        .position(point(px(0.), px(ADI_BACKEND_COMBO_HEIGHT + 8.)))
107
+                        .child(
108
+                            div()
109
+                                .w(px(ADI_BACKEND_POPOVER_WIDTH))
110
+                                .max_h(px(ADI_BACKEND_POPOVER_MAX_HEIGHT))
111
+                                .rounded_md()
112
+                                .border_1()
113
+                                .border_color(rgb(0xd8e0df))
114
+                                .bg(rgb(0xf8fbfa))
115
+                                .id("adi-backend-popover-scroll")
116
+                                .overflow_y_scroll()
117
+                                .scrollbar_width(px(8.))
118
+                                .occlude()
119
+                                .child(popover),
120
+                        ),
121
+                )
122
+                .with_priority(10),
123
+            ),
124
+        )
125
+    })
126
+}
127
+
128
+fn settings_adi_backend_combo_button(backend: &AdiBackendOption, open: bool) -> gpui::Div {
129
+    let status = backend.availability.label();
130
+
131
+    div()
132
+        .h(px(ADI_BACKEND_COMBO_HEIGHT))
133
+        .p_3()
134
+        .rounded_md()
135
+        .border_1()
136
+        .border_color(if open { rgb(0x0f6f7a) } else { rgb(0xcfd8d6) })
137
+        .bg(rgb(0xffffff))
138
+        .cursor_pointer()
139
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
140
+        .flex()
141
+        .items_center()
142
+        .gap_3()
143
+        .child(status_dot(&status))
144
+        .child(
145
+            div()
146
+                .min_w_0()
147
+                .flex()
148
+                .flex_1()
149
+                .flex_col()
150
+                .gap_1()
151
+                .child(
152
+                    div()
153
+                        .text_sm()
154
+                        .font_weight(FontWeight::SEMIBOLD)
155
+                        .text_color(rgb(0x24333a))
156
+                        .text_ellipsis()
157
+                        .child(backend.name.clone()),
158
+                )
159
+                .child(
160
+                    div()
161
+                        .text_xs()
162
+                        .text_color(rgb(0x6a7a81))
163
+                        .text_ellipsis()
164
+                        .child(backend.detail.clone()),
165
+                ),
166
+        )
167
+        .child(status_badge(status, false))
168
+        .child(chevron(open))
169
+}
170
+
171
+fn settings_adi_backend_option(backend: &AdiBackendOption, selected: bool) -> gpui::Div {
172
+    let status = backend.availability.label();
173
+
174
+    div()
175
+        .min_h(px(56.))
176
+        .p_2()
177
+        .rounded_md()
178
+        .border_1()
179
+        .border_color(if selected {
180
+            rgb(0x0f6f7a)
181
+        } else {
182
+            rgb(0xd8e0df)
183
+        })
184
+        .bg(if selected {
185
+            rgb(0xf0fbfa)
186
+        } else {
187
+            rgb(0xffffff)
188
+        })
189
+        .cursor_pointer()
190
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
191
+        .flex()
192
+        .items_center()
193
+        .gap_2()
194
+        .child(status_dot(&status))
195
+        .child(
196
+            div()
197
+                .min_w_0()
198
+                .flex()
199
+                .flex_1()
200
+                .flex_col()
201
+                .gap_0p5()
202
+                .child(
203
+                    div()
204
+                        .text_sm()
205
+                        .font_weight(FontWeight::SEMIBOLD)
206
+                        .text_color(rgb(0x24333a))
207
+                        .text_ellipsis()
208
+                        .child(backend.name.clone()),
209
+                )
210
+                .child(
211
+                    div()
212
+                        .text_xs()
213
+                        .text_color(rgb(0x6a7a81))
214
+                        .text_ellipsis()
215
+                        .child(backend.detail.clone()),
216
+                ),
217
+        )
218
+        .child(status_badge(status, selected))
219
+}
220
+
221
+fn adi_backend_status_section(
222
+    backend: &AdiBackendOption,
223
+    adi_operation: Option<&AdiOperationState>,
224
+    spinner_turns: f32,
225
+    cx: &mut Context<SettingsWindow>,
226
+) -> gpui::Div {
227
+    let status = backend.availability.label();
228
+    let color = backend_status_color(&status);
229
+    let coreadi_operation = adi_operation.filter(|operation| operation.is_coreadi_install());
230
+    let provisioning_operation = adi_operation.filter(|operation| operation.is_provisioning());
231
+    let provisioning_label = if provisioning_operation.is_some() {
232
+        "Provisioning".into()
233
+    } else {
234
+        backend.provisioning_state.label()
235
+    };
236
+
237
+    div()
238
+        .p_3()
239
+        .rounded_md()
240
+        .border_1()
241
+        .border_color(rgb(0xd8e0df))
242
+        .bg(rgb(0xffffff))
243
+        .flex()
244
+        .items_start()
245
+        .gap_3()
246
+        .child(
247
+            div()
248
+                .flex_none()
249
+                .w(px(4.))
250
+                .h_full()
251
+                .min_h(px(76.))
252
+                .rounded_full()
253
+                .bg(rgb(color)),
254
+        )
255
+        .child(
256
+            div()
257
+                .min_w_0()
258
+                .flex()
259
+                .flex_1()
260
+                .flex_col()
261
+                .gap_2()
262
+                .child(
263
+                    div()
264
+                        .min_w_0()
265
+                        .flex()
266
+                        .flex_wrap()
267
+                        .items_center()
268
+                        .justify_between()
269
+                        .gap_2()
270
+                        .child(
271
+                            div()
272
+                                .min_w_0()
273
+                                .flex()
274
+                                .items_center()
275
+                                .gap_2()
276
+                                .child(status_dot(&status))
277
+                                .child(
278
+                                    div()
279
+                                        .text_sm()
280
+                                        .font_weight(FontWeight::SEMIBOLD)
281
+                                        .text_color(rgb(0x24333a))
282
+                                        .child("Status"),
283
+                                ),
284
+                        )
285
+                        .child(status_badge(status, false)),
286
+                )
287
+                .child(
288
+                    div()
289
+                        .flex()
290
+                        .items_center()
291
+                        .gap_2()
292
+                        .child(settings_label("Provisioning"))
293
+                        .child(status_badge(provisioning_label, false)),
294
+                )
295
+                .when_some(backend.provisioning_state.detail(), |this, detail| {
296
+                    this.child(
297
+                        div()
298
+                            .min_w_0()
299
+                            .text_xs()
300
+                            .line_height(px(17.))
301
+                            .text_color(rgb(0x8a4a46))
302
+                            .whitespace_normal()
303
+                            .child(detail),
304
+                    )
305
+                })
306
+                .children(backend.details.iter().map(|detail| {
307
+                    adi_backend_detail_row(detail.label.clone(), detail.value.clone())
308
+                }))
309
+                .when(
310
+                    matches!(
311
+                        &backend.provisioning_state,
312
+                        AdiProvisioningState::Provisioned
313
+                    ),
314
+                    |this| {
315
+                        this.child(adi_status_button(
316
+                            "erase-adi-provisioning",
317
+                            "Erase Provisioning",
318
+                            false,
319
+                            0xf4e9e7,
320
+                            0x7d3430,
321
+                            cx.listener(SettingsWindow::erase_adi_backend_provisioning),
322
+                        ))
323
+                    },
324
+                )
325
+                .when(
326
+                    matches!(
327
+                        &backend.provisioning_state,
328
+                        AdiProvisioningState::NotProvisioned | AdiProvisioningState::Error(_)
329
+                    ),
330
+                    |this| {
331
+                        this.child(adi_provision_button(
332
+                            provisioning_operation,
333
+                            spinner_turns,
334
+                            cx,
335
+                        ))
336
+                    },
337
+                )
338
+                .when(backend.repair_action.is_some(), |this| {
339
+                    let label = backend
340
+                        .repair_action
341
+                        .map(|action| action.label())
342
+                        .unwrap_or_else(|| "Fix Issues".into());
343
+                    this.child(adi_repair_button(
344
+                        label,
345
+                        color,
346
+                        coreadi_operation,
347
+                        spinner_turns,
348
+                        cx,
349
+                    ))
350
+                    .when(backend.kind == AdiBackendKind::AndroidCoreAdi, |this| {
351
+                        this.child(android_coreadi_apk_link(adi_operation.is_some(), cx))
352
+                    })
353
+                }),
354
+        )
355
+}
356
+
357
+fn adi_provision_button(
358
+    provisioning_operation: Option<&AdiOperationState>,
359
+    spinner_turns: f32,
360
+    cx: &mut Context<SettingsWindow>,
361
+) -> impl IntoElement {
362
+    let provisioning = provisioning_operation.is_some();
363
+    let progress = provisioning_operation
364
+        .map(AdiOperationState::progress)
365
+        .unwrap_or(0.);
366
+    let turn = if provisioning_operation.is_some_and(AdiOperationState::is_indeterminate) {
367
+        spinner_turns
368
+    } else {
369
+        0.
370
+    };
371
+    let label = provisioning_operation
372
+        .map(AdiOperationState::label)
373
+        .unwrap_or_else(|| "Provision device".into());
374
+
375
+    div()
376
+        .id("provision-adi")
377
+        .h_8()
378
+        .px_3()
379
+        .rounded_md()
380
+        .bg(rgb(0x173f45))
381
+        .text_color(rgb(0xffffff))
382
+        .text_xs()
383
+        .font_weight(FontWeight::SEMIBOLD)
384
+        .flex()
385
+        .items_center()
386
+        .justify_center()
387
+        .gap_2()
388
+        .when(!provisioning, |this| {
389
+            this.cursor_pointer()
390
+                .hover(|style| style.opacity(0.88))
391
+                .on_click(cx.listener(SettingsWindow::provision_adi_backend))
392
+        })
393
+        .when_some(provisioning_operation, |this, _| {
394
+            this.child(progress_circle(progress, turn))
395
+        })
396
+        .child(label)
397
+}
398
+
399
+fn adi_repair_button(
400
+    label: String,
401
+    color: u32,
402
+    coreadi_install: Option<&AdiOperationState>,
403
+    spinner_turns: f32,
404
+    cx: &mut Context<SettingsWindow>,
405
+) -> impl IntoElement {
406
+    let installing = coreadi_install.is_some();
407
+    let progress_label = coreadi_install
408
+        .map(AdiOperationState::label)
409
+        .unwrap_or_else(|| label.into());
410
+    let progress = coreadi_install
411
+        .map(AdiOperationState::progress)
412
+        .unwrap_or(0.);
413
+    let turn = if coreadi_install.is_some_and(AdiOperationState::is_indeterminate) {
414
+        spinner_turns
415
+    } else {
416
+        0.
417
+    };
418
+
419
+    div()
420
+        .id("repair-adi-backend")
421
+        .h_8()
422
+        .px_3()
423
+        .rounded_md()
424
+        .bg(rgb(color))
425
+        .text_color(rgb(0xffffff))
426
+        .text_xs()
427
+        .font_weight(FontWeight::SEMIBOLD)
428
+        .flex()
429
+        .items_center()
430
+        .justify_center()
431
+        .gap_2()
432
+        .when(!installing, |this| {
433
+            this.cursor_pointer()
434
+                .hover(|style| style.opacity(0.88))
435
+                .on_click(cx.listener(SettingsWindow::repair_adi_backend))
436
+        })
437
+        .when_some(coreadi_install, |this, _| {
438
+            this.child(progress_circle(progress, turn))
439
+        })
440
+        .child(progress_label)
441
+}
442
+
443
+fn adi_backend_detail_row(label: String, value: String) -> gpui::Div {
444
+    div()
445
+        .min_w_0()
446
+        .flex()
447
+        .items_center()
448
+        .gap_2()
449
+        .child(
450
+            div()
451
+                .w(px(112.))
452
+                .flex_none()
453
+                .text_xs()
454
+                .font_weight(FontWeight::SEMIBOLD)
455
+                .text_color(rgb(0x66767c))
456
+                .child(label),
457
+        )
458
+        .child(
459
+            div()
460
+                .min_w_0()
461
+                .flex_1()
462
+                .text_xs()
463
+                .line_height(px(17.))
464
+                .text_color(rgb(0x53666d))
465
+                .text_ellipsis()
466
+                .child(value),
467
+        )
468
+}
469
+
470
+fn adi_status_button(
471
+    id: &'static str,
472
+    label: &'static str,
473
+    disabled: bool,
474
+    bg: u32,
475
+    color: u32,
476
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
477
+) -> impl IntoElement {
478
+    div()
479
+        .id(id)
480
+        .h_8()
481
+        .px_3()
482
+        .rounded_md()
483
+        .bg(rgb(bg))
484
+        .text_color(rgb(color))
485
+        .text_xs()
486
+        .font_weight(FontWeight::SEMIBOLD)
487
+        .flex()
488
+        .items_center()
489
+        .justify_center()
490
+        .when(disabled, |this| this.opacity(0.45))
491
+        .when(!disabled, |this| {
492
+            this.cursor_pointer()
493
+                .hover(|style| style.opacity(0.88))
494
+                .on_click(listener)
495
+        })
496
+        .child(label)
497
+}
498
+
499
+fn android_coreadi_apk_link(disabled: bool, cx: &mut Context<SettingsWindow>) -> gpui::Div {
500
+    div()
501
+        .w_full()
502
+        .flex()
503
+        .items_center()
504
+        .justify_center()
505
+        .gap_1()
506
+        .text_xs()
507
+        .text_color(rgb(0x6a7a81))
508
+        .child("or")
509
+        .child(
510
+            div()
511
+                .id("select-coreadi-apk")
512
+                .text_color(rgb(0x0f6f7a))
513
+                .font_weight(FontWeight::SEMIBOLD)
514
+                .when(disabled, |this| this.opacity(0.45))
515
+                .when(!disabled, |this| {
516
+                    this.cursor_pointer()
517
+                        .hover(|style| style.text_color(rgb(0x173f45)))
518
+                        .on_click(cx.listener(SettingsWindow::select_coreadi_apk))
519
+                })
520
+                .child("Select APK from the disk..."),
521
+        )
522
+}
523
+
524
+fn machine_identity_section(
525
+    identity: &MachineIdentity,
526
+    editable: bool,
527
+    editing: Option<&MachineIdentityEdit>,
528
+    cx: &mut Context<SettingsWindow>,
529
+) -> gpui::Div {
530
+    div()
531
+        .p_3()
532
+        .rounded_md()
533
+        .border_1()
534
+        .border_color(rgb(0xd8e0df))
535
+        .bg(rgb(0xffffff))
536
+        .flex()
537
+        .flex_col()
538
+        .gap_3()
539
+        .child(
540
+            div()
541
+                .flex()
542
+                .items_center()
543
+                .gap_2()
544
+                .child(
545
+                    div()
546
+                        .text_sm()
547
+                        .font_weight(FontWeight::SEMIBOLD)
548
+                        .text_color(rgb(0x24333a))
549
+                        .child("Machine Identity"),
550
+                )
551
+                .when(!editable, |this| this.child(read_only_badge())),
552
+        )
553
+        .child(machine_identity_row(
554
+            MachineIdentityField::MachineModel,
555
+            identity.machine_name.clone(),
556
+            editable,
557
+            editing,
558
+            cx,
559
+        ))
560
+        .child(machine_identity_row(
561
+            MachineIdentityField::OsName,
562
+            identity.os_name.clone(),
563
+            editable,
564
+            editing,
565
+            cx,
566
+        ))
567
+        .child(machine_identity_row(
568
+            MachineIdentityField::OsVersion,
569
+            identity.os_version.clone(),
570
+            editable,
571
+            editing,
572
+            cx,
573
+        ))
574
+        .child(machine_identity_row(
575
+            MachineIdentityField::MachineId,
576
+            identity.machine_id.clone(),
577
+            editable,
578
+            editing,
579
+            cx,
580
+        ))
581
+}
582
+
583
+fn machine_identity_row(
584
+    field: MachineIdentityField,
585
+    value: SharedString,
586
+    editable: bool,
587
+    editing: Option<&MachineIdentityEdit>,
588
+    cx: &mut Context<SettingsWindow>,
589
+) -> gpui::Div {
590
+    let label = field.label();
591
+    let is_editing = editing.is_some_and(|edit| edit.field == field);
592
+
593
+    div()
594
+        .min_w_0()
595
+        .flex()
596
+        .items_center()
597
+        .gap_3()
598
+        .child(
599
+            div()
600
+                .w(px(104.))
601
+                .flex_none()
602
+                .text_xs()
603
+                .text_color(rgb(0x7b8a90))
604
+                .child(label),
605
+        )
606
+        .when_some(editing.filter(|edit| edit.field == field), |this, edit| {
607
+            this.child(machine_identity_editor(&edit.input))
608
+                .child(machine_identity_icon_button(
609
+                    ("save-machine-identity", machine_identity_id(field)),
610
+                    "icons/check.svg",
611
+                    cx.listener(SettingsWindow::save_machine_identity_edit_from_button),
612
+                ))
613
+                .child(machine_identity_icon_button(
614
+                    ("cancel-machine-identity", machine_identity_id(field)),
615
+                    "icons/x.svg",
616
+                    cx.listener(SettingsWindow::cancel_machine_identity_edit),
617
+                ))
618
+        })
619
+        .when(!is_editing, |this| {
620
+            this.child(
621
+                div()
622
+                    .min_w_0()
623
+                    .flex_1()
624
+                    .text_sm()
625
+                    .text_color(rgb(0x405057))
626
+                    .text_ellipsis()
627
+                    .child(value),
628
+            )
629
+            .when(editable, |this| {
630
+                this.child(
631
+                    div()
632
+                        .id(("edit-machine-identity", machine_identity_id(field)))
633
+                        .flex_none()
634
+                        .w_6()
635
+                        .h_6()
636
+                        .rounded_md()
637
+                        .bg(rgb(0xebf1f0))
638
+                        .text_color(rgb(0x53666d))
639
+                        .flex()
640
+                        .items_center()
641
+                        .justify_center()
642
+                        .cursor_pointer()
643
+                        .hover(|style| style.bg(rgb(0xdfe8e6)))
644
+                        .on_click(cx.listener(move |settings, event, window, cx| {
645
+                            settings.begin_machine_identity_edit(field, event, window, cx)
646
+                        }))
647
+                        .child(lucide_icon("icons/pencil.svg")),
648
+                )
649
+            })
650
+        })
651
+}
652
+
653
+fn machine_identity_icon_button(
654
+    id: (&'static str, usize),
655
+    icon: &'static str,
656
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
657
+) -> impl IntoElement {
658
+    div()
659
+        .id(id)
660
+        .flex_none()
661
+        .w_6()
662
+        .h_6()
663
+        .rounded_md()
664
+        .bg(rgb(0xebf1f0))
665
+        .text_color(rgb(0x53666d))
666
+        .flex()
667
+        .items_center()
668
+        .justify_center()
669
+        .cursor_pointer()
670
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
671
+        .on_click(listener)
672
+        .child(lucide_icon(icon))
673
+}
674
+
675
+fn machine_identity_id(field: MachineIdentityField) -> usize {
676
+    match field {
677
+        MachineIdentityField::MachineModel => 0,
678
+        MachineIdentityField::OsName => 1,
679
+        MachineIdentityField::OsVersion => 2,
680
+        MachineIdentityField::MachineId => 3,
681
+    }
682
+}
683
+
684
+fn status_badge(label: String, selected: bool) -> gpui::Div {
685
+    let color = backend_status_color(&label);
686
+
687
+    div()
688
+        .flex_none()
689
+        .px_2()
690
+        .h_6()
691
+        .rounded_full()
692
+        .bg(if selected {
693
+            rgb(status_badge_selected_bg(&label))
694
+        } else {
695
+            rgb(status_badge_bg(&label))
696
+        })
697
+        .text_color(rgb(color))
698
+        .text_xs()
699
+        .font_weight(FontWeight::SEMIBOLD)
700
+        .flex()
701
+        .items_center()
702
+        .justify_center()
703
+        .child(label)
704
+}
705
+
706
+fn status_dot(status: &str) -> gpui::Div {
707
+    div()
708
+        .flex_none()
709
+        .w_2()
710
+        .h_2()
711
+        .rounded_full()
712
+        .bg(rgb(backend_status_color(status)))
713
+}
714
+
715
+fn backend_status_color(status: &str) -> u32 {
716
+    match status {
717
+        "Ready" => 0x1d6b45,
718
+        "Needs setup" => 0x9a6a14,
719
+        "Offline" => 0x9a302b,
720
+        "Provisioned" => 0x1d6b45,
721
+        "Provisioning" => 0x1d6b45,
722
+        "Not provisioned" => 0x9a6a14,
723
+        "Not available" => 0x53666d,
724
+        "Failed" => 0x9a302b,
725
+        _ => 0x53666d,
726
+    }
727
+}
728
+
729
+fn status_badge_bg(status: &str) -> u32 {
730
+    match status {
731
+        "Ready" => 0xe5f3ec,
732
+        "Needs setup" => 0xf6edda,
733
+        "Offline" => 0xf4e4e2,
734
+        "Provisioned" => 0xe5f3ec,
735
+        "Provisioning" => 0xe5f3ec,
736
+        "Not provisioned" => 0xf6edda,
737
+        "Not available" => 0xebf1f0,
738
+        "Failed" => 0xf4e4e2,
739
+        _ => 0xebf1f0,
740
+    }
741
+}
742
+
743
+fn status_badge_selected_bg(status: &str) -> u32 {
744
+    match status {
745
+        "Ready" => 0xd3eadc,
746
+        "Needs setup" => 0xeadbb8,
747
+        "Offline" => 0xebcecb,
748
+        "Provisioned" => 0xd3eadc,
749
+        "Provisioning" => 0xd3eadc,
750
+        "Not provisioned" => 0xeadbb8,
751
+        "Not available" => 0xdfe8e6,
752
+        "Failed" => 0xebcecb,
753
+        _ => 0xd7efec,
754
+    }
755
+}
756
+
757
+fn read_only_badge() -> gpui::Div {
758
+    div()
759
+        .px_2()
760
+        .h_5()
761
+        .rounded_full()
762
+        .bg(rgb(0xf0f2f2))
763
+        .text_color(rgb(0x718086))
764
+        .text_xs()
765
+        .font_weight(FontWeight::SEMIBOLD)
766
+        .flex()
767
+        .items_center()
768
+        .justify_center()
769
+        .child("Read-only")
770
+}
... ...
@@ -0,0 +1,929 @@
1
+use super::*;
2
+
3
+pub(super) fn render(
4
+    focus_handle: &FocusHandle,
5
+    scroll_handle: &ScrollHandle,
6
+    app: &AppOption,
7
+    enabled_patches: &[bool],
8
+    team_id: &SharedString,
9
+    app_detail_edit: Option<&AppDetailEdit>,
10
+    selected_entitlement: Option<usize>,
11
+    entitlement_edit: Option<&EntitlementEdit>,
12
+    entitlement_type_picker_open: bool,
13
+    cx: &mut Context<SettingsWindow>,
14
+) -> gpui::Div {
15
+    let patch_summary = enabled_patch_summary(app, enabled_patches);
16
+
17
+    settings_window_shell()
18
+        .track_focus(focus_handle)
19
+        .capture_key_down(cx.listener(SettingsWindow::handle_app_settings_key))
20
+        .child(scroll_panel(
21
+            "app-settings-scroll",
22
+            scroll_handle,
23
+            div()
24
+                .flex()
25
+                .flex_col()
26
+                .gap_4()
27
+                .child(settings_window_header(
28
+                    "App Settings",
29
+                    "Review metadata and entitlements before signing.",
30
+                ))
31
+                .child(
32
+                    div()
33
+                        .flex()
34
+                        .gap_3()
35
+                        .p_3()
36
+                        .rounded_md()
37
+                        .border_1()
38
+                        .border_color(rgb(0xd8e0df))
39
+                        .bg(rgb(0xffffff))
40
+                        .child(app_icon_editor(app, cx))
41
+                        .child(
42
+                            div()
43
+                                .min_w_0()
44
+                                .flex()
45
+                                .flex_1()
46
+                                .flex_col()
47
+                                .gap_2()
48
+                                .child(app_detail_row(
49
+                                    AppMetadataField::Name,
50
+                                    app,
51
+                                    true,
52
+                                    app_detail_edit,
53
+                                    cx,
54
+                                ))
55
+                                .child(app_detail_row(
56
+                                    AppMetadataField::BundleId,
57
+                                    app,
58
+                                    true,
59
+                                    app_detail_edit,
60
+                                    cx,
61
+                                ))
62
+                                .child(app_detail_row(
63
+                                    AppMetadataField::Version,
64
+                                    app,
65
+                                    true,
66
+                                    app_detail_edit,
67
+                                    cx,
68
+                                ))
69
+                                .child(app_detail_row(
70
+                                    AppMetadataField::Build,
71
+                                    app,
72
+                                    true,
73
+                                    app_detail_edit,
74
+                                    cx,
75
+                                )),
76
+                        ),
77
+                )
78
+                .child(
79
+                    div()
80
+                        .flex()
81
+                        .flex_col()
82
+                        .gap_2()
83
+                        .p_3()
84
+                        .rounded_md()
85
+                        .border_1()
86
+                        .border_color(rgb(0xd8e0df))
87
+                        .bg(rgb(0xffffff))
88
+                        .child(app_detail_row(
89
+                            AppMetadataField::Executable,
90
+                            app,
91
+                            true,
92
+                            app_detail_edit,
93
+                            cx,
94
+                        ))
95
+                        .child(app_detail_row(
96
+                            AppMetadataField::MinimumOs,
97
+                            app,
98
+                            true,
99
+                            app_detail_edit,
100
+                            cx,
101
+                        ))
102
+                        .child(app_detail_row(
103
+                            AppMetadataField::SupportedDevices,
104
+                            app,
105
+                            true,
106
+                            app_detail_edit,
107
+                            cx,
108
+                        ))
109
+                        .child(read_only_app_detail_row("IPA", app.path.to_string()))
110
+                        .child(read_only_app_detail_row("Patches", patch_summary)),
111
+                )
112
+                .child(entitlements_table(
113
+                    app,
114
+                    team_id,
115
+                    selected_entitlement,
116
+                    entitlement_edit,
117
+                    entitlement_type_picker_open,
118
+                    cx,
119
+                )),
120
+        ))
121
+}
122
+
123
+fn app_icon_editor(app: &AppOption, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
124
+    let initial = app
125
+        .name()
126
+        .to_string()
127
+        .chars()
128
+        .next()
129
+        .map(|c| c.to_string())
130
+        .unwrap_or_else(|| "A".to_string());
131
+    let icon_path = app.displayed_icon_path().map(|path| path.to_string());
132
+
133
+    div()
134
+        .flex_none()
135
+        .w(px(84.))
136
+        .h(px(84.))
137
+        .rounded_md()
138
+        .border_1()
139
+        .border_color(rgb(0xcfd8d6))
140
+        .bg(rgb(0x173f45))
141
+        .text_color(rgb(0xffffff))
142
+        .overflow_hidden()
143
+        .cursor_pointer()
144
+        .hover(|style| style.bg(rgb(0x20545c)))
145
+        .flex()
146
+        .flex_col()
147
+        .items_center()
148
+        .justify_center()
149
+        .gap_1()
150
+        .id("app-icon-editor")
151
+        .on_click(cx.listener(SettingsWindow::edit_app_icon))
152
+        .child(if let Some(icon_path) = icon_path {
153
+            div().size_full().overflow_hidden().child(
154
+                img(Arc::<std::path::Path>::from(std::path::PathBuf::from(
155
+                    icon_path,
156
+                )))
157
+                .size_full()
158
+                .object_fit(ObjectFit::Cover),
159
+            )
160
+        } else {
161
+            div()
162
+                .flex()
163
+                .flex_col()
164
+                .items_center()
165
+                .justify_center()
166
+                .gap_1()
167
+                .text_2xl()
168
+                .font_weight(FontWeight::BOLD)
169
+                .child(initial)
170
+                .child(div().text_xs().child("Edit icon"))
171
+        })
172
+}
173
+
174
+fn app_detail_row(
175
+    field: AppMetadataField,
176
+    app: &AppOption,
177
+    editable: bool,
178
+    edit: Option<&AppDetailEdit>,
179
+    cx: &mut Context<SettingsWindow>,
180
+) -> gpui::Div {
181
+    let is_editing = edit.is_some_and(|edit| edit.field() == field);
182
+    let changed = app.field_is_overridden(field);
183
+
184
+    div()
185
+        .min_w_0()
186
+        .flex()
187
+        .items_center()
188
+        .gap_3()
189
+        .child(
190
+            div()
191
+                .w(px(96.))
192
+                .flex_none()
193
+                .text_xs()
194
+                .text_color(rgb(0x7b8a90))
195
+                .child(field.label()),
196
+        )
197
+        .child(
198
+            if let Some(AppDetailEdit::Text { input, .. }) =
199
+                edit.filter(|edit| edit.field() == field)
200
+            {
201
+                machine_identity_editor(input)
202
+            } else if let Some(AppDetailEdit::SupportedDevices { selected }) =
203
+                edit.filter(|edit| edit.field() == field)
204
+            {
205
+                supported_devices_editor(selected, cx)
206
+            } else {
207
+                div()
208
+                    .min_w_0()
209
+                    .flex_1()
210
+                    .text_sm()
211
+                    .text_color(rgb(if changed { 0x173f45 } else { 0x405057 }))
212
+                    .text_ellipsis()
213
+                    .child(app.field_display_value(field))
214
+            },
215
+        )
216
+        .when(editable, |this| {
217
+            this.child(app_detail_controls(field, changed, is_editing, cx))
218
+        })
219
+}
220
+
221
+fn supported_devices_editor(
222
+    selected: &[SupportedDeviceFamily],
223
+    cx: &mut Context<SettingsWindow>,
224
+) -> gpui::Div {
225
+    div()
226
+        .min_w_0()
227
+        .flex_1()
228
+        .flex()
229
+        .flex_wrap()
230
+        .gap_1()
231
+        .children(SupportedDeviceFamily::ALL.into_iter().map(|family| {
232
+            let active = selected.contains(&family);
233
+            div()
234
+                .id(("supported-device-family", family as usize))
235
+                .px_2()
236
+                .h_6()
237
+                .rounded_md()
238
+                .border_1()
239
+                .border_color(if active { rgb(0x168291) } else { rgb(0xd8e0df) })
240
+                .bg(if active { rgb(0xf0fbfa) } else { rgb(0xffffff) })
241
+                .text_color(rgb(if active { 0x173f45 } else { 0x53666d }))
242
+                .text_xs()
243
+                .font_weight(FontWeight::SEMIBOLD)
244
+                .flex()
245
+                .items_center()
246
+                .justify_center()
247
+                .cursor_pointer()
248
+                .hover(|style| style.bg(rgb(0xf6f9f9)))
249
+                .on_click(cx.listener(move |settings, event, window, cx| {
250
+                    settings.toggle_supported_device_family(family, event, window, cx)
251
+                }))
252
+                .child(family.label())
253
+        }))
254
+}
255
+
256
+fn read_only_app_detail_row(label: &'static str, value: String) -> gpui::Div {
257
+    div()
258
+        .min_w_0()
259
+        .flex()
260
+        .items_center()
261
+        .gap_3()
262
+        .child(
263
+            div()
264
+                .w(px(96.))
265
+                .flex_none()
266
+                .text_xs()
267
+                .text_color(rgb(0x7b8a90))
268
+                .child(label),
269
+        )
270
+        .child(
271
+            div()
272
+                .min_w_0()
273
+                .flex_1()
274
+                .text_sm()
275
+                .text_color(rgb(0x405057))
276
+                .text_ellipsis()
277
+                .child(value),
278
+        )
279
+}
280
+
281
+fn app_detail_controls(
282
+    field: AppMetadataField,
283
+    changed: bool,
284
+    editing: bool,
285
+    cx: &mut Context<SettingsWindow>,
286
+) -> impl IntoElement {
287
+    div()
288
+        .flex_none()
289
+        .w(px(86.))
290
+        .flex()
291
+        .items_center()
292
+        .justify_end()
293
+        .gap_1()
294
+        .child(app_detail_revert_slot(field, changed, cx))
295
+        .when(editing, |this| {
296
+            this.child(confirm_app_detail_button(cx))
297
+                .child(cancel_app_detail_button(cx))
298
+        })
299
+        .when(!editing, |this| this.child(edit_button(field, cx)))
300
+}
301
+
302
+fn edit_button(field: AppMetadataField, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
303
+    div()
304
+        .flex_none()
305
+        .w_6()
306
+        .h_6()
307
+        .rounded_md()
308
+        .bg(rgb(0xebf1f0))
309
+        .text_color(rgb(0x53666d))
310
+        .flex()
311
+        .items_center()
312
+        .justify_center()
313
+        .cursor_pointer()
314
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
315
+        .id(("edit-app-detail", app_detail_id(field)))
316
+        .on_click(cx.listener(move |settings, event, window, cx| {
317
+            settings.begin_app_detail_edit(field, event, window, cx)
318
+        }))
319
+        .child(lucide_icon("icons/pencil.svg"))
320
+}
321
+
322
+fn confirm_app_detail_button(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
323
+    div()
324
+        .flex_none()
325
+        .w_6()
326
+        .h_6()
327
+        .rounded_md()
328
+        .bg(rgb(0xd3eadc))
329
+        .text_color(rgb(0x1d6b45))
330
+        .flex()
331
+        .items_center()
332
+        .justify_center()
333
+        .cursor_pointer()
334
+        .hover(|style| style.bg(rgb(0xc5e2d1)))
335
+        .id("save-app-detail")
336
+        .on_click(cx.listener(SettingsWindow::save_app_detail_edit_from_button))
337
+        .child(lucide_icon("icons/check.svg"))
338
+}
339
+
340
+fn cancel_app_detail_button(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
341
+    div()
342
+        .flex_none()
343
+        .w_6()
344
+        .h_6()
345
+        .rounded_md()
346
+        .bg(rgb(0xebcecb))
347
+        .text_color(rgb(0x9a302b))
348
+        .flex()
349
+        .items_center()
350
+        .justify_center()
351
+        .cursor_pointer()
352
+        .hover(|style| style.bg(rgb(0xe2c0bd)))
353
+        .id("cancel-app-detail")
354
+        .on_click(cx.listener(SettingsWindow::cancel_app_detail_edit))
355
+        .child(lucide_icon("icons/x.svg"))
356
+}
357
+
358
+fn app_detail_revert_slot(
359
+    field: AppMetadataField,
360
+    changed: bool,
361
+    cx: &mut Context<SettingsWindow>,
362
+) -> impl IntoElement {
363
+    div()
364
+        .flex_none()
365
+        .w(px(52.))
366
+        .h_6()
367
+        .rounded_md()
368
+        .bg(rgb(0xebf1f0))
369
+        .text_color(rgb(0x53666d))
370
+        .text_xs()
371
+        .font_weight(FontWeight::SEMIBOLD)
372
+        .flex()
373
+        .items_center()
374
+        .justify_center()
375
+        .id(("revert-app-detail", app_detail_id(field)))
376
+        .when(!changed, |this| this.opacity(0.))
377
+        .when(changed, |this| {
378
+            this.cursor_pointer()
379
+                .hover(|style| style.bg(rgb(0xdfe8e6)))
380
+                .on_click(cx.listener(move |settings, event, window, cx| {
381
+                    settings.revert_app_detail_field(field, event, window, cx)
382
+                }))
383
+        })
384
+        .child("Revert")
385
+}
386
+
387
+fn app_detail_id(field: AppMetadataField) -> usize {
388
+    match field {
389
+        AppMetadataField::Name => 0,
390
+        AppMetadataField::BundleId => 1,
391
+        AppMetadataField::Version => 2,
392
+        AppMetadataField::Build => 3,
393
+        AppMetadataField::Executable => 4,
394
+        AppMetadataField::MinimumOs => 5,
395
+        AppMetadataField::SupportedDevices => 6,
396
+    }
397
+}
398
+
399
+fn enabled_patch_summary(app: &AppOption, enabled_patches: &[bool]) -> String {
400
+    let enabled = app
401
+        .patches
402
+        .iter()
403
+        .enumerate()
404
+        .filter_map(|(index, patch)| {
405
+            enabled_patches
406
+                .get(index)
407
+                .copied()
408
+                .filter(|enabled| *enabled)
409
+                .map(|_| format!("{} ({})", patch.name, patch.detail))
410
+        })
411
+        .collect::<Vec<_>>();
412
+
413
+    if enabled.is_empty() {
414
+        "No patch changes selected".to_string()
415
+    } else {
416
+        enabled.join(", ")
417
+    }
418
+}
419
+
420
+fn entitlements_table(
421
+    app: &AppOption,
422
+    team_id: &SharedString,
423
+    selected_entitlement: Option<usize>,
424
+    edit: Option<&EntitlementEdit>,
425
+    type_picker_open: bool,
426
+    cx: &mut Context<SettingsWindow>,
427
+) -> gpui::Div {
428
+    let changed = app.entitlements_are_overridden();
429
+    let entitlements = app.effective_entitlements(team_id);
430
+    let default_entitlements = app.default_effective_entitlements(team_id);
431
+
432
+    div()
433
+        .flex()
434
+        .flex_col()
435
+        .rounded_md()
436
+        .border_1()
437
+        .border_color(rgb(0xd8e0df))
438
+        .bg(rgb(0xffffff))
439
+        .overflow_hidden()
440
+        .child(
441
+            div()
442
+                .px_3()
443
+                .py_2()
444
+                .border_b_1()
445
+                .border_color(rgb(0xd8e0df))
446
+                .flex()
447
+                .items_center()
448
+                .justify_start()
449
+                .gap_2()
450
+                .child(
451
+                    div()
452
+                        .text_sm()
453
+                        .font_weight(FontWeight::SEMIBOLD)
454
+                        .text_color(rgb(0x24333a))
455
+                        .child("Entitlements"),
456
+                )
457
+                .child(entitlements_actions(changed, cx)),
458
+        )
459
+        .child(entitlement_header_row())
460
+        .children(entitlements.iter().enumerate().map(|(index, entitlement)| {
461
+            let row_changed = changed
462
+                && entitlement_revert_target(entitlement, index, &default_entitlements).as_ref()
463
+                    != Some(entitlement);
464
+            entitlement_row(
465
+                index,
466
+                entitlement,
467
+                selected_entitlement == Some(index),
468
+                row_changed,
469
+                edit,
470
+                type_picker_open,
471
+                cx,
472
+            )
473
+        }))
474
+}
475
+
476
+fn entitlements_actions(changed: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
477
+    div()
478
+        .flex_none()
479
+        .flex()
480
+        .items_center()
481
+        .gap_1()
482
+        .child(entitlement_icon_button(
483
+            "add-entitlement",
484
+            "icons/plus.svg",
485
+            cx.listener(SettingsWindow::add_entitlement),
486
+        ))
487
+        .child(entitlement_icon_button(
488
+            "remove-entitlement",
489
+            "icons/minus.svg",
490
+            cx.listener(SettingsWindow::remove_entitlement),
491
+        ))
492
+        .child(entitlements_revert_slot(changed, cx))
493
+}
494
+
495
+fn entitlement_icon_button(
496
+    id: &'static str,
497
+    icon: &'static str,
498
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
499
+) -> impl IntoElement {
500
+    div()
501
+        .flex_none()
502
+        .w_6()
503
+        .h_6()
504
+        .rounded_md()
505
+        .bg(rgb(0xebf1f0))
506
+        .text_color(rgb(0x53666d))
507
+        .flex()
508
+        .items_center()
509
+        .justify_center()
510
+        .cursor_pointer()
511
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
512
+        .id(id)
513
+        .on_click(listener)
514
+        .child(lucide_icon(icon))
515
+}
516
+
517
+fn entitlements_revert_slot(changed: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
518
+    div()
519
+        .flex_none()
520
+        .w_6()
521
+        .h_6()
522
+        .rounded_md()
523
+        .bg(rgb(0xebf1f0))
524
+        .text_color(rgb(0x53666d))
525
+        .flex()
526
+        .items_center()
527
+        .justify_center()
528
+        .id("revert-entitlements")
529
+        .when(!changed, |this| this.opacity(0.))
530
+        .when(changed, |this| {
531
+            this.cursor_pointer()
532
+                .hover(|style| style.bg(rgb(0xdfe8e6)))
533
+                .on_click(cx.listener(SettingsWindow::revert_entitlements))
534
+        })
535
+        .child(lucide_icon("icons/rotate-ccw.svg"))
536
+}
537
+
538
+fn entitlement_header_row() -> gpui::Div {
539
+    div()
540
+        .grid()
541
+        .grid_cols(3)
542
+        .px_3()
543
+        .py_2()
544
+        .bg(rgb(0xf6f9f9))
545
+        .border_b_1()
546
+        .border_color(rgb(0xd8e0df))
547
+        .text_xs()
548
+        .font_weight(FontWeight::SEMIBOLD)
549
+        .text_color(rgb(0x66767c))
550
+        .child("Key")
551
+        .child("Type")
552
+        .child("Value")
553
+}
554
+
555
+fn entitlement_row(
556
+    index: usize,
557
+    entitlement: &AppEntitlement,
558
+    selected: bool,
559
+    changed: bool,
560
+    edit: Option<&EntitlementEdit>,
561
+    type_picker_open: bool,
562
+    cx: &mut Context<SettingsWindow>,
563
+) -> gpui::Div {
564
+    div()
565
+        .grid()
566
+        .grid_cols(3)
567
+        .px_3()
568
+        .py_1()
569
+        .border_b_1()
570
+        .border_color(rgb(0xe8eeee))
571
+        .text_xs()
572
+        .text_color(rgb(0x405057))
573
+        .bg(if selected {
574
+            rgb(0xf0fbfa)
575
+        } else {
576
+            rgb(0xffffff)
577
+        })
578
+        .child(entitlement_cell(
579
+            index,
580
+            EntitlementField::Key,
581
+            entitlement,
582
+            edit,
583
+            type_picker_open,
584
+            false,
585
+            cx,
586
+        ))
587
+        .child(entitlement_cell(
588
+            index,
589
+            EntitlementField::ValueType,
590
+            entitlement,
591
+            edit,
592
+            type_picker_open,
593
+            false,
594
+            cx,
595
+        ))
596
+        .child(entitlement_cell(
597
+            index,
598
+            EntitlementField::Value,
599
+            entitlement,
600
+            edit,
601
+            type_picker_open,
602
+            changed,
603
+            cx,
604
+        ))
605
+}
606
+
607
+fn entitlement_cell(
608
+    row: usize,
609
+    field: EntitlementField,
610
+    entitlement: &AppEntitlement,
611
+    edit: Option<&EntitlementEdit>,
612
+    type_picker_open: bool,
613
+    row_revert_visible: bool,
614
+    cx: &mut Context<SettingsWindow>,
615
+) -> impl IntoElement {
616
+    let is_editing = edit.is_some_and(|edit| edit.row() == row && edit.field() == field);
617
+    div()
618
+        .min_w_0()
619
+        .min_h_7()
620
+        .flex()
621
+        .items_center()
622
+        .gap_1()
623
+        .rounded_sm()
624
+        .cursor_pointer()
625
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
626
+        .id(("entitlement-cell", row * 3 + entitlement_field_id(field)))
627
+        .on_click(cx.listener(move |settings, event, window, cx| {
628
+            settings.begin_entitlement_edit(row, field, event, window, cx)
629
+        }))
630
+        .child(
631
+            if let Some(EntitlementEdit::Text { input, .. }) =
632
+                edit.filter(|edit| edit.row() == row && edit.field() == field)
633
+            {
634
+                machine_identity_editor(input)
635
+            } else if let Some(EntitlementEdit::Type { selected, .. }) =
636
+                edit.filter(|edit| edit.row() == row && edit.field() == field)
637
+            {
638
+                entitlement_type_editor(selected, type_picker_open, cx)
639
+            } else if let Some(EntitlementEdit::Array { items, .. }) =
640
+                edit.filter(|edit| edit.row() == row && edit.field() == field)
641
+            {
642
+                array_entitlement_editor(items, cx)
643
+            } else if let Some(EntitlementEdit::Boolean { value, .. }) =
644
+                edit.filter(|edit| edit.row() == row && edit.field() == field)
645
+            {
646
+                boolean_entitlement_editor(*value, cx)
647
+            } else {
648
+                div()
649
+                    .min_w_0()
650
+                    .flex_1()
651
+                    .text_ellipsis()
652
+                    .child(entitlement_field_value(entitlement, field).clone())
653
+            },
654
+        )
655
+        .when(
656
+            row_revert_visible && field == EntitlementField::Value && !is_editing,
657
+            |this| this.child(entitlement_row_revert_button(row, cx)),
658
+        )
659
+        .when(is_editing, |this| {
660
+            this.child(entitlement_edit_button(
661
+                ("save-entitlement", row * 3 + entitlement_field_id(field)),
662
+                "icons/check.svg",
663
+                cx.listener(SettingsWindow::save_entitlement_edit_from_button),
664
+            ))
665
+            .child(entitlement_edit_button(
666
+                ("cancel-entitlement", row * 3 + entitlement_field_id(field)),
667
+                "icons/x.svg",
668
+                cx.listener(SettingsWindow::cancel_entitlement_edit),
669
+            ))
670
+        })
671
+}
672
+
673
+fn entitlement_row_revert_button(row: usize, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
674
+    div()
675
+        .id(("revert-entitlement", row))
676
+        .flex_none()
677
+        .w_6()
678
+        .h_6()
679
+        .rounded_md()
680
+        .bg(rgb(0xebf1f0))
681
+        .text_color(rgb(0x53666d))
682
+        .flex()
683
+        .items_center()
684
+        .justify_center()
685
+        .cursor_pointer()
686
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
687
+        .on_click(cx.listener(move |settings, event, window, cx| {
688
+            settings.revert_entitlement(row, event, window, cx)
689
+        }))
690
+        .child(lucide_icon("icons/rotate-ccw.svg"))
691
+}
692
+
693
+fn boolean_entitlement_editor(value: bool, cx: &mut Context<SettingsWindow>) -> gpui::Div {
694
+    div().min_w_0().flex_1().child(
695
+        div()
696
+            .id("boolean-entitlement-editor")
697
+            .flex()
698
+            .items_center()
699
+            .gap_2()
700
+            .cursor_pointer()
701
+            .on_click(cx.listener(SettingsWindow::toggle_boolean_entitlement_value))
702
+            .child(
703
+                div()
704
+                    .flex_none()
705
+                    .w_5()
706
+                    .h_5()
707
+                    .rounded_sm()
708
+                    .border_1()
709
+                    .border_color(if value { rgb(0x168291) } else { rgb(0xcfd8d6) })
710
+                    .bg(if value { rgb(0xd3eadc) } else { rgb(0xffffff) })
711
+                    .text_color(rgb(0x1d6b45))
712
+                    .flex()
713
+                    .items_center()
714
+                    .justify_center()
715
+                    .when(value, |this| this.child(lucide_icon("icons/check.svg"))),
716
+            )
717
+            .child(
718
+                div()
719
+                    .min_w_0()
720
+                    .text_xs()
721
+                    .text_color(rgb(0x405057))
722
+                    .child(if value { "true" } else { "false" }),
723
+            ),
724
+    )
725
+}
726
+
727
+fn array_entitlement_editor(
728
+    items: &[Entity<EditLine>],
729
+    cx: &mut Context<SettingsWindow>,
730
+) -> gpui::Div {
731
+    div()
732
+        .min_w_0()
733
+        .flex_1()
734
+        .flex()
735
+        .flex_col()
736
+        .gap_1()
737
+        .children(items.iter().enumerate().map(|(index, input)| {
738
+            div()
739
+                .min_w_0()
740
+                .flex()
741
+                .items_center()
742
+                .gap_1()
743
+                .child(machine_identity_editor(input))
744
+                .child(array_entitlement_item_button(
745
+                    ("remove-entitlement-array-item", index),
746
+                    "icons/minus.svg",
747
+                    cx.listener(move |settings, event, window, cx| {
748
+                        settings.remove_entitlement_array_item(index, event, window, cx)
749
+                    }),
750
+                ))
751
+        }))
752
+        .child(
753
+            div()
754
+                .id("add-entitlement-array-item")
755
+                .h_6()
756
+                .px_2()
757
+                .rounded_md()
758
+                .bg(rgb(0xebf1f0))
759
+                .text_color(rgb(0x53666d))
760
+                .text_xs()
761
+                .font_weight(FontWeight::SEMIBOLD)
762
+                .flex()
763
+                .items_center()
764
+                .justify_center()
765
+                .gap_1()
766
+                .cursor_pointer()
767
+                .hover(|style| style.bg(rgb(0xdfe8e6)))
768
+                .on_click(cx.listener(SettingsWindow::add_entitlement_array_item))
769
+                .child(lucide_icon("icons/plus.svg"))
770
+                .child("Add item"),
771
+        )
772
+}
773
+
774
+fn array_entitlement_item_button(
775
+    id: (&'static str, usize),
776
+    icon: &'static str,
777
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
778
+) -> impl IntoElement {
779
+    div()
780
+        .id(id)
781
+        .flex_none()
782
+        .w_6()
783
+        .h_6()
784
+        .rounded_md()
785
+        .bg(rgb(0xebf1f0))
786
+        .text_color(rgb(0x53666d))
787
+        .flex()
788
+        .items_center()
789
+        .justify_center()
790
+        .cursor_pointer()
791
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
792
+        .on_click(listener)
793
+        .child(lucide_icon(icon))
794
+}
795
+
796
+fn entitlement_type_editor(
797
+    selected: &SharedString,
798
+    open: bool,
799
+    cx: &mut Context<SettingsWindow>,
800
+) -> gpui::Div {
801
+    div()
802
+        .relative()
803
+        .min_w_0()
804
+        .flex_1()
805
+        .child(
806
+            div()
807
+                .id("entitlement-type-combobox")
808
+                .h_7()
809
+                .px_2()
810
+                .rounded_md()
811
+                .border_1()
812
+                .border_color(if open { rgb(0x0f6f7a) } else { rgb(0xcfd8d6) })
813
+                .bg(rgb(0xffffff))
814
+                .flex()
815
+                .items_center()
816
+                .gap_2()
817
+                .cursor_pointer()
818
+                .hover(|style| style.bg(rgb(0xf6f9f9)))
819
+                .on_click(cx.listener(SettingsWindow::toggle_entitlement_type_picker))
820
+                .child(
821
+                    div()
822
+                        .min_w_0()
823
+                        .flex_1()
824
+                        .text_ellipsis()
825
+                        .text_xs()
826
+                        .text_color(rgb(0x405057))
827
+                        .child(selected.clone()),
828
+                )
829
+                .child(chevron(open)),
830
+        )
831
+        .when(open, |this| {
832
+            this.child(
833
+                div().absolute().top_0().left_0().w(px(0.)).h(px(0.)).child(
834
+                    deferred(
835
+                        anchored()
836
+                            .snap_to_window_with_margin(px(8.))
837
+                            .position_mode(AnchoredPositionMode::Local)
838
+                            .position(point(px(0.), px(34.)))
839
+                            .child(
840
+                                div()
841
+                                    .w(px(160.))
842
+                                    .rounded_md()
843
+                                    .border_1()
844
+                                    .border_color(rgb(0xd8e0df))
845
+                                    .bg(rgb(0xf8fbfa))
846
+                                    .occlude()
847
+                                    .child(dropdown_list(
848
+                                        EntitlementValue::EDITABLE_TYPE_LABELS
849
+                                            .into_iter()
850
+                                            .enumerate()
851
+                                            .map(|(index, label)| {
852
+                                                entitlement_type_option(
853
+                                                    index,
854
+                                                    label,
855
+                                                    label == selected.as_ref(),
856
+                                                    cx,
857
+                                                )
858
+                                            }),
859
+                                    )),
860
+                            ),
861
+                    )
862
+                    .with_priority(10),
863
+                ),
864
+            )
865
+        })
866
+}
867
+
868
+fn entitlement_type_option(
869
+    index: usize,
870
+    label: &'static str,
871
+    selected: bool,
872
+    cx: &mut Context<SettingsWindow>,
873
+) -> impl IntoElement {
874
+    div()
875
+        .id(("entitlement-type-option", index))
876
+        .h_7()
877
+        .px_2()
878
+        .rounded_sm()
879
+        .bg(if selected {
880
+            rgb(0xf0fbfa)
881
+        } else {
882
+            rgb(0xffffff)
883
+        })
884
+        .text_color(rgb(if selected { 0x173f45 } else { 0x405057 }))
885
+        .text_xs()
886
+        .font_weight(if selected {
887
+            FontWeight::SEMIBOLD
888
+        } else {
889
+            FontWeight::NORMAL
890
+        })
891
+        .flex()
892
+        .items_center()
893
+        .cursor_pointer()
894
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
895
+        .on_click(cx.listener(move |settings, event, window, cx| {
896
+            settings.select_entitlement_type(label, event, window, cx)
897
+        }))
898
+        .child(label)
899
+}
900
+
901
+fn entitlement_field_id(field: EntitlementField) -> usize {
902
+    match field {
903
+        EntitlementField::Key => 0,
904
+        EntitlementField::ValueType => 1,
905
+        EntitlementField::Value => 2,
906
+    }
907
+}
908
+
909
+fn entitlement_edit_button(
910
+    id: (&'static str, usize),
911
+    icon: &'static str,
912
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
913
+) -> impl IntoElement {
914
+    div()
915
+        .id(id)
916
+        .flex_none()
917
+        .w_6()
918
+        .h_6()
919
+        .rounded_md()
920
+        .bg(rgb(0xebf1f0))
921
+        .text_color(rgb(0x53666d))
922
+        .flex()
923
+        .items_center()
924
+        .justify_center()
925
+        .cursor_pointer()
926
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
927
+        .on_click(listener)
928
+        .child(lucide_icon(icon))
929
+}
... ...
@@ -0,0 +1,252 @@
1
+use super::*;
2
+
3
+pub(super) fn render(
4
+    focus_handle: &FocusHandle,
5
+    scroll_handle: &ScrollHandle,
6
+    login: &DeveloperLoginState,
7
+    cx: &mut Context<SettingsWindow>,
8
+) -> gpui::Div {
9
+    let content = div().flex().flex_col().gap_4().child(match login.step {
10
+        DeveloperLoginStep::Credentials => credentials_form(login, cx),
11
+        DeveloperLoginStep::TwoFactor => two_factor_form(login, cx),
12
+    });
13
+
14
+    settings_window_shell()
15
+        .track_focus(focus_handle)
16
+        .capture_key_down(cx.listener(SettingsWindow::handle_developer_login_key))
17
+        .gap_4()
18
+        .child(settings_window_header(
19
+            "Add Apple Account",
20
+            "Sign in before choosing developer teams, App IDs, and signing resources.",
21
+        ))
22
+        .child(scroll_panel(
23
+            "developer-login-scroll",
24
+            scroll_handle,
25
+            content,
26
+        ))
27
+}
28
+
29
+fn credentials_form(login: &DeveloperLoginState, cx: &mut Context<SettingsWindow>) -> gpui::Div {
30
+    developer_login_card()
31
+        .child(login_error(login.error.as_ref()))
32
+        .child(login_input("Email", &login.email))
33
+        .child(login_input("Password", &login.password))
34
+        .child(remember_account_checkbox(login.remember_account, cx))
35
+        .child(primary_login_button(
36
+            "Continue",
37
+            cx.listener(SettingsWindow::submit_developer_login),
38
+        ))
39
+        .child(mock_accounts_note())
40
+}
41
+
42
+fn two_factor_form(login: &DeveloperLoginState, cx: &mut Context<SettingsWindow>) -> gpui::Div {
43
+    developer_login_card()
44
+        .child(
45
+            div()
46
+                .p_3()
47
+                .rounded_md()
48
+                .bg(rgb(0xf6f9f9))
49
+                .flex()
50
+                .flex_col()
51
+                .gap_1()
52
+                .child(
53
+                    div()
54
+                        .text_sm()
55
+                        .font_weight(FontWeight::SEMIBOLD)
56
+                        .text_color(rgb(0x24333a))
57
+                        .child("Two-Factor Authentication"),
58
+                )
59
+                .child(
60
+                    div()
61
+                        .text_xs()
62
+                        .text_color(rgb(0x6a7a81))
63
+                        .child(login.two_factor_detail.clone()),
64
+                ),
65
+        )
66
+        .child(login_error(login.error.as_ref()))
67
+        .child(login_input("Verification code", &login.code))
68
+        .child(
69
+            div()
70
+                .flex()
71
+                .items_center()
72
+                .gap_2()
73
+                .child(secondary_login_button(
74
+                    "Back",
75
+                    cx.listener(SettingsWindow::back_to_developer_login),
76
+                ))
77
+                .child(primary_login_button(
78
+                    "Verify",
79
+                    cx.listener(SettingsWindow::submit_developer_two_factor),
80
+                )),
81
+        )
82
+}
83
+
84
+fn developer_login_card() -> gpui::Div {
85
+    div()
86
+        .p_4()
87
+        .rounded_md()
88
+        .border_1()
89
+        .border_color(rgb(0xd8e0df))
90
+        .bg(rgb(0xffffff))
91
+        .flex()
92
+        .flex_col()
93
+        .gap_3()
94
+}
95
+
96
+fn login_input(label: &'static str, input: &Entity<EditLine>) -> gpui::Div {
97
+    div()
98
+        .flex()
99
+        .flex_col()
100
+        .gap_1()
101
+        .child(settings_label(label))
102
+        .child(
103
+            div()
104
+                .h_8()
105
+                .px_2()
106
+                .rounded_md()
107
+                .border_1()
108
+                .border_color(rgb(0xcdd8d6))
109
+                .bg(rgb(0xffffff))
110
+                .text_sm()
111
+                .text_color(rgb(0x24333a))
112
+                .flex()
113
+                .items_center()
114
+                .overflow_hidden()
115
+                .child(input.clone()),
116
+        )
117
+}
118
+
119
+fn login_error(error: Option<&SharedString>) -> gpui::Div {
120
+    div().when_some(error, |this, error| {
121
+        this.p_3()
122
+            .rounded_md()
123
+            .border_1()
124
+            .border_color(rgb(0xe8c6c2))
125
+            .bg(rgb(0xfff7f6))
126
+            .text_sm()
127
+            .text_color(rgb(0x8b302b))
128
+            .child(error.clone())
129
+    })
130
+}
131
+
132
+fn remember_account_checkbox(checked: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
133
+    div()
134
+        .id("remember-developer-account")
135
+        .p_2()
136
+        .rounded_md()
137
+        .bg(rgb(0xf6f9f9))
138
+        .flex()
139
+        .items_center()
140
+        .gap_2()
141
+        .cursor_pointer()
142
+        .hover(|style| style.bg(rgb(0xebf1f0)))
143
+        .on_click(cx.listener(SettingsWindow::toggle_remember_developer_account))
144
+        .child(
145
+            div()
146
+                .flex_none()
147
+                .w_4()
148
+                .h_4()
149
+                .rounded_sm()
150
+                .border_1()
151
+                .border_color(if checked {
152
+                    rgb(0x173f45)
153
+                } else {
154
+                    rgb(0xaebcba)
155
+                })
156
+                .bg(if checked {
157
+                    rgb(0x173f45)
158
+                } else {
159
+                    rgb(0xffffff)
160
+                })
161
+                .flex()
162
+                .items_center()
163
+                .justify_center()
164
+                .when(checked, |this| {
165
+                    this.text_color(rgb(0xffffff))
166
+                        .child(lucide_icon("icons/check.svg"))
167
+                }),
168
+        )
169
+        .child(
170
+            div()
171
+                .min_w_0()
172
+                .flex()
173
+                .flex_col()
174
+                .gap_0p5()
175
+                .child(
176
+                    div()
177
+                        .text_xs()
178
+                        .font_weight(FontWeight::SEMIBOLD)
179
+                        .text_color(rgb(0x24333a))
180
+                        .child("Save my account"),
181
+                )
182
+                .child(
183
+                    div()
184
+                        .text_xs()
185
+                        .text_color(rgb(0x6a7a81))
186
+                        .child("Use the account ID for system keyring storage when login is real."),
187
+                ),
188
+        )
189
+}
190
+
191
+fn primary_login_button(
192
+    label: &'static str,
193
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
194
+) -> impl IntoElement {
195
+    div()
196
+        .id("developer-login-primary")
197
+        .h_9()
198
+        .rounded_md()
199
+        .bg(rgb(0x173f45))
200
+        .text_color(rgb(0xffffff))
201
+        .text_sm()
202
+        .font_weight(FontWeight::SEMIBOLD)
203
+        .flex()
204
+        .items_center()
205
+        .justify_center()
206
+        .cursor_pointer()
207
+        .hover(|style| style.bg(rgb(0x24565d)))
208
+        .on_click(listener)
209
+        .child(label)
210
+}
211
+
212
+fn secondary_login_button(
213
+    label: &'static str,
214
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
215
+) -> impl IntoElement {
216
+    div()
217
+        .id("developer-login-secondary")
218
+        .h_9()
219
+        .px_4()
220
+        .rounded_md()
221
+        .bg(rgb(0xebf1f0))
222
+        .text_color(rgb(0x53666d))
223
+        .text_sm()
224
+        .font_weight(FontWeight::SEMIBOLD)
225
+        .flex()
226
+        .items_center()
227
+        .justify_center()
228
+        .cursor_pointer()
229
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
230
+        .on_click(listener)
231
+        .child(label)
232
+}
233
+
234
+fn mock_accounts_note() -> gpui::Div {
235
+    div()
236
+        .p_3()
237
+        .rounded_md()
238
+        .bg(rgb(0xf6f9f9))
239
+        .text_xs()
240
+        .text_color(rgb(0x6a7a81))
241
+        .flex()
242
+        .flex_col()
243
+        .gap_1()
244
+        .child(
245
+            div()
246
+                .font_weight(FontWeight::SEMIBOLD)
247
+                .text_color(rgb(0x53666d))
248
+                .child("Mock accounts"),
249
+        )
250
+        .child("a@example.com requires 2FA code 123456.")
251
+        .child("b@example.com signs in without 2FA.")
252
+}
... ...
@@ -0,0 +1,422 @@
1
+use super::*;
2
+
3
+pub(super) fn render(
4
+    focus_handle: &FocusHandle,
5
+    scroll_handle: &ScrollHandle,
6
+    teams: &[TeamOption],
7
+    selected_team: usize,
8
+    auto_app_id: bool,
9
+    selected_app_id: usize,
10
+    team_picker_open: bool,
11
+    app_id_picker_open: bool,
12
+    cx: &mut Context<SettingsWindow>,
13
+) -> gpui::Div {
14
+    let selected = selected_team;
15
+    let team = teams.get(selected);
16
+    let mut content = div()
17
+        .flex()
18
+        .flex_col()
19
+        .gap_4()
20
+        .when_some(team, |this, team| {
21
+            this.child(developer_settings_section(
22
+                "Developer Team",
23
+                "Team used for certificates, profiles, and signing identities.",
24
+                div()
25
+                    .flex()
26
+                    .flex_col()
27
+                    .gap_3()
28
+                    .child(combo_with_popover(
29
+                        "team-popover-scroll",
30
+                        combo_button(
31
+                            team.identifier.clone(),
32
+                            team.name.clone(),
33
+                            developer_account_title(&team.role),
34
+                            team_picker_open,
35
+                        )
36
+                        .id("settings-team-combobox")
37
+                        .on_click(cx.listener(SettingsWindow::toggle_team_picker)),
38
+                        team_picker_open,
39
+                        dropdown_list((0..teams.len()).map(|index| {
40
+                            settings_team_row(&teams[index], index == selected)
41
+                                .id(("settings-team-row", index))
42
+                                .on_click(cx.listener(move |this, event, window, cx| {
43
+                                    this.select_team(index, event, window, cx)
44
+                                }))
45
+                        }))
46
+                        .id("settings-team-options"),
47
+                    ))
48
+                    .child(properties_list(vec![
49
+                        ("Name", team.name.to_string()),
50
+                        ("Team ID", team.identifier.to_string()),
51
+                        ("Role", team.role.to_string()),
52
+                    ])),
53
+            ))
54
+        });
55
+
56
+    if let Some(team) = team {
57
+        content = content.child(app_id_section(
58
+            team,
59
+            auto_app_id,
60
+            selected_app_id,
61
+            app_id_picker_open,
62
+            cx,
63
+        ));
64
+    }
65
+    content = content.child(developer_logout_button(cx));
66
+
67
+    settings_window_shell()
68
+        .track_focus(focus_handle)
69
+        .capture_key_down(cx.listener(SettingsWindow::handle_machine_identity_editor_key))
70
+        .gap_4()
71
+        .child(settings_window_header_with_action(
72
+            settings_window_header(
73
+                "Developer Settings",
74
+                "Configure developer-team resources used during signing.",
75
+            ),
76
+            team_refresh_button(cx),
77
+        ))
78
+        .child(scroll_panel("team-settings-scroll", scroll_handle, content))
79
+}
80
+
81
+fn team_refresh_button(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
82
+    div()
83
+        .id("refresh-team-details")
84
+        .flex_none()
85
+        .h_8()
86
+        .px_3()
87
+        .rounded_md()
88
+        .bg(rgb(0xebf1f0))
89
+        .text_color(rgb(0x53666d))
90
+        .text_xs()
91
+        .font_weight(FontWeight::SEMIBOLD)
92
+        .flex()
93
+        .items_center()
94
+        .justify_center()
95
+        .gap_2()
96
+        .cursor_pointer()
97
+        .hover(|style| style.bg(rgb(0xdfe8e6)))
98
+        .on_click(cx.listener(SettingsWindow::refresh_team_details))
99
+        .child(lucide_icon("icons/refresh-cw.svg"))
100
+        .child("Refresh")
101
+}
102
+
103
+fn developer_logout_button(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
104
+    div()
105
+        .id("developer-log-out")
106
+        .p_3()
107
+        .rounded_md()
108
+        .border_1()
109
+        .border_color(rgb(0xe0d3d1))
110
+        .bg(rgb(0xffffff))
111
+        .text_color(rgb(0x7d3430))
112
+        .flex()
113
+        .items_center()
114
+        .justify_between()
115
+        .gap_3()
116
+        .cursor_pointer()
117
+        .hover(|style| style.bg(rgb(0xfff7f6)))
118
+        .on_click(cx.listener(SettingsWindow::log_out_developer_account))
119
+        .child(
120
+            div()
121
+                .min_w_0()
122
+                .flex()
123
+                .flex_col()
124
+                .gap_0p5()
125
+                .child(
126
+                    div()
127
+                        .text_sm()
128
+                        .font_weight(FontWeight::SEMIBOLD)
129
+                        .child("Log Out"),
130
+                )
131
+                .child(
132
+                    div()
133
+                        .text_xs()
134
+                        .text_color(rgb(0x8b6b67))
135
+                        .child("Remove this Apple Account session from Super Sideloader."),
136
+                ),
137
+        )
138
+        .child(
139
+            div()
140
+                .flex_none()
141
+                .w_7()
142
+                .h_7()
143
+                .rounded_md()
144
+                .bg(rgb(0xf4e9e7))
145
+                .flex()
146
+                .items_center()
147
+                .justify_center()
148
+                .child(lucide_icon("icons/log-out.svg")),
149
+        )
150
+}
151
+
152
+fn developer_settings_section(
153
+    title: &'static str,
154
+    detail: &'static str,
155
+    body: impl IntoElement,
156
+) -> gpui::Div {
157
+    div()
158
+        .p_3()
159
+        .rounded_md()
160
+        .border_1()
161
+        .border_color(rgb(0xd8e0df))
162
+        .bg(rgb(0xffffff))
163
+        .flex()
164
+        .flex_col()
165
+        .gap_3()
166
+        .child(
167
+            div()
168
+                .flex()
169
+                .flex_col()
170
+                .gap_0p5()
171
+                .child(
172
+                    div()
173
+                        .text_sm()
174
+                        .font_weight(FontWeight::SEMIBOLD)
175
+                        .text_color(rgb(0x24333a))
176
+                        .child(title),
177
+                )
178
+                .child(div().text_xs().text_color(rgb(0x6a7a81)).child(detail)),
179
+        )
180
+        .child(body)
181
+}
182
+
183
+fn settings_team_row(team: &TeamOption, selected: bool) -> gpui::Div {
184
+    div()
185
+        .h(px(COMBO_ITEM_HEIGHT))
186
+        .px_3()
187
+        .rounded_md()
188
+        .border_1()
189
+        .border_color(if selected {
190
+            rgb(0x0f6f7a)
191
+        } else {
192
+            rgb(0xd8e0df)
193
+        })
194
+        .bg(if selected {
195
+            rgb(0xf0fbfa)
196
+        } else {
197
+            rgb(0xffffff)
198
+        })
199
+        .cursor_pointer()
200
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
201
+        .flex()
202
+        .items_center()
203
+        .child(combo_item_content(
204
+            team.identifier.clone(),
205
+            team.name.clone(),
206
+            developer_account_title(&team.role),
207
+        ))
208
+}
209
+
210
+fn app_id_section(
211
+    team: &TeamOption,
212
+    auto_app_id: bool,
213
+    selected_app_id: usize,
214
+    picker_open: bool,
215
+    cx: &mut Context<SettingsWindow>,
216
+) -> gpui::Div {
217
+    let manual_enabled = !auto_app_id;
218
+    let selected_app_id = selected_app_id.min(team.app_ids.len().saturating_sub(1));
219
+    let selected = team.app_ids.get(selected_app_id);
220
+
221
+    developer_settings_section(
222
+        "App ID",
223
+        "Identifier used when creating or selecting the provisioning profile.",
224
+        div()
225
+            .flex()
226
+            .flex_col()
227
+            .gap_3()
228
+            .child(auto_app_id_checkbox(auto_app_id, cx))
229
+            .child(
230
+                div()
231
+                    .flex()
232
+                    .flex_col()
233
+                    .gap_2()
234
+                    .child(settings_label("Manual App ID"))
235
+                    .child(
236
+                        div()
237
+                            .flex()
238
+                            .items_center()
239
+                            .gap_2()
240
+                            .when_some(selected, |this, selected| {
241
+                                this.child(
242
+                                    div()
243
+                                        .min_w_0()
244
+                                        .flex_1()
245
+                                        .when(!manual_enabled, |this| this.opacity(0.5))
246
+                                        .child(combo_with_popover(
247
+                                            "app-id-popover-scroll",
248
+                                            combo_button(
249
+                                                selected.identifier.clone(),
250
+                                                selected.name.clone(),
251
+                                                selected.kind.clone(),
252
+                                                picker_open && manual_enabled,
253
+                                            )
254
+                                            .id("app-id-combobox")
255
+                                            .when(
256
+                                                manual_enabled,
257
+                                                |this| {
258
+                                                    this.on_click(cx.listener(
259
+                                                        SettingsWindow::toggle_app_id_picker,
260
+                                                    ))
261
+                                                },
262
+                                            ),
263
+                                            picker_open && manual_enabled,
264
+                                            dropdown_list((0..team.app_ids.len()).map(|index| {
265
+                                                settings_app_id_row(
266
+                                                    &team.app_ids[index],
267
+                                                    index == selected_app_id,
268
+                                                )
269
+                                                .id(("app-id-row", index))
270
+                                                .on_click(cx.listener(
271
+                                                    move |this, event, window, cx| {
272
+                                                        this.select_app_id(index, event, window, cx)
273
+                                                    },
274
+                                                ))
275
+                                            }))
276
+                                            .id("app-id-options"),
277
+                                        )),
278
+                                )
279
+                            })
280
+                            .child(app_id_actions(!manual_enabled, cx)),
281
+                    ),
282
+            ),
283
+    )
284
+}
285
+
286
+fn auto_app_id_checkbox(checked: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
287
+    div()
288
+        .id("auto-app-id")
289
+        .p_2()
290
+        .rounded_md()
291
+        .bg(rgb(0xf6f9f9))
292
+        .flex()
293
+        .items_center()
294
+        .gap_2()
295
+        .cursor_pointer()
296
+        .hover(|style| style.bg(rgb(0xebf1f0)))
297
+        .on_click(cx.listener(SettingsWindow::toggle_auto_app_id))
298
+        .child(
299
+            div()
300
+                .flex_none()
301
+                .w_4()
302
+                .h_4()
303
+                .rounded_sm()
304
+                .border_1()
305
+                .border_color(if checked {
306
+                    rgb(0x173f45)
307
+                } else {
308
+                    rgb(0xaebcba)
309
+                })
310
+                .bg(if checked {
311
+                    rgb(0x173f45)
312
+                } else {
313
+                    rgb(0xffffff)
314
+                })
315
+                .flex()
316
+                .items_center()
317
+                .justify_center()
318
+                .when(checked, |this| {
319
+                    this.child(div().w_2().h_2().rounded_sm().bg(rgb(0xffffff)))
320
+                }),
321
+        )
322
+        .child(
323
+            div()
324
+                .min_w_0()
325
+                .flex()
326
+                .flex_1()
327
+                .flex_col()
328
+                .gap_0p5()
329
+                .child(
330
+                    div()
331
+                        .text_xs()
332
+                        .font_weight(FontWeight::SEMIBOLD)
333
+                        .text_color(rgb(0x24333a))
334
+                        .child("Automatically pick or create an App ID"),
335
+                )
336
+                .child(
337
+                    div()
338
+                        .text_xs()
339
+                        .text_color(rgb(0x6a7a81))
340
+                        .child("Manual App ID selection stays visible below."),
341
+                ),
342
+        )
343
+}
344
+
345
+fn app_id_actions(disabled: bool, cx: &mut Context<SettingsWindow>) -> impl IntoElement {
346
+    div()
347
+        .flex_none()
348
+        .flex()
349
+        .items_center()
350
+        .gap_1()
351
+        .child(app_id_icon_button(
352
+            "add-app-id",
353
+            "icons/plus.svg",
354
+            disabled,
355
+            cx.listener(SettingsWindow::add_app_id),
356
+        ))
357
+        .child(app_id_icon_button(
358
+            "remove-app-id",
359
+            "icons/minus.svg",
360
+            disabled,
361
+            cx.listener(SettingsWindow::remove_app_id),
362
+        ))
363
+        .child(app_id_icon_button(
364
+            "edit-app-id",
365
+            "icons/pencil.svg",
366
+            disabled,
367
+            cx.listener(SettingsWindow::edit_app_id),
368
+        ))
369
+}
370
+
371
+fn app_id_icon_button(
372
+    id: &'static str,
373
+    icon: &'static str,
374
+    disabled: bool,
375
+    listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
376
+) -> impl IntoElement {
377
+    div()
378
+        .flex_none()
379
+        .w_6()
380
+        .h_6()
381
+        .rounded_md()
382
+        .bg(rgb(0xebf1f0))
383
+        .text_color(rgb(0x53666d))
384
+        .flex()
385
+        .items_center()
386
+        .justify_center()
387
+        .id(id)
388
+        .when(disabled, |this| this.opacity(0.45))
389
+        .when(!disabled, |this| {
390
+            this.cursor_pointer()
391
+                .hover(|style| style.bg(rgb(0xdfe8e6)))
392
+                .on_click(listener)
393
+        })
394
+        .child(lucide_icon(icon))
395
+}
396
+
397
+fn settings_app_id_row(app_id: &AppIdOption, selected: bool) -> gpui::Div {
398
+    div()
399
+        .h(px(COMBO_ITEM_HEIGHT))
400
+        .px_3()
401
+        .rounded_md()
402
+        .border_1()
403
+        .border_color(if selected {
404
+            rgb(0x0f6f7a)
405
+        } else {
406
+            rgb(0xd8e0df)
407
+        })
408
+        .bg(if selected {
409
+            rgb(0xf0fbfa)
410
+        } else {
411
+            rgb(0xffffff)
412
+        })
413
+        .cursor_pointer()
414
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
415
+        .flex()
416
+        .items_center()
417
+        .child(combo_item_content(
418
+            app_id.identifier.clone(),
419
+            app_id.name.clone(),
420
+            app_id.kind.clone(),
421
+        ))
422
+}
... ...
@@ -0,0 +1,163 @@
1
+use crate::models::MachineIdentity;
2
+use std::env;
3
+
4
+pub(crate) fn machine_identity() -> MachineIdentity {
5
+    platform_machine_identity().unwrap_or_else(fallback_machine_identity)
6
+}
7
+
8
+fn fallback_machine_identity() -> MachineIdentity {
9
+    let machine_name = env::var("HOSTNAME")
10
+        .or_else(|_| env::var("COMPUTERNAME"))
11
+        .unwrap_or_else(|_| "Local Machine".to_string());
12
+    let os_name = env::consts::OS.to_string();
13
+    let os_version = env::var("OS_VERSION").unwrap_or_else(|_| "Unknown".to_string());
14
+    let machine_id = env::var("MACHINE_ID")
15
+        .unwrap_or_else(|_| "A8B31C86-359B-4D95-8950-BA5DD8FFC46F".to_string());
16
+
17
+    MachineIdentity {
18
+        machine_name: machine_name.into(),
19
+        os_name: os_name.into(),
20
+        os_version: os_version.into(),
21
+        machine_id: machine_id.into(),
22
+    }
23
+}
24
+
25
+#[cfg(target_os = "macos")]
26
+fn platform_machine_identity() -> Option<MachineIdentity> {
27
+    let machine_name = macos::hardware_model()?;
28
+    let (os_name, os_version) = macos::operating_system()?;
29
+    let machine_id = macos::platform_uuid()?;
30
+
31
+    Some(MachineIdentity {
32
+        machine_name: machine_name.into(),
33
+        os_name: os_name.into(),
34
+        os_version: os_version.into(),
35
+        machine_id: machine_id.into(),
36
+    })
37
+}
38
+
39
+#[cfg(not(target_os = "macos"))]
40
+fn platform_machine_identity() -> Option<MachineIdentity> {
41
+    None
42
+}
43
+
44
+#[cfg(target_os = "macos")]
45
+mod macos {
46
+    use core_foundation::base::{kCFAllocatorDefault, CFAllocatorRef, CFTypeRef, TCFType};
47
+    use core_foundation::dictionary::{CFDictionaryRef, CFMutableDictionaryRef};
48
+    use core_foundation::string::{CFString, CFStringRef};
49
+    use libc::{c_char, c_void, size_t};
50
+    use plist::{Dictionary, Value};
51
+    use std::ffi::CString;
52
+    use std::ptr;
53
+
54
+    type KernReturn = i32;
55
+    type MachPort = u32;
56
+    type IoObject = u32;
57
+    type IoRegistryEntry = IoObject;
58
+    type IoService = IoObject;
59
+
60
+    const K_IO_MAIN_PORT_DEFAULT: MachPort = 0;
61
+    const K_IO_PLATFORM_EXPERT_DEVICE: &[u8] = b"IOPlatformExpertDevice\0";
62
+    const K_IO_PLATFORM_UUID: &str = "IOPlatformUUID";
63
+    const SYSTEM_VERSION_PLIST: &str = "/System/Library/CoreServices/SystemVersion.plist";
64
+
65
+    #[link(name = "IOKit", kind = "framework")]
66
+    extern "C" {
67
+        fn IOServiceMatching(name: *const c_char) -> CFMutableDictionaryRef;
68
+        fn IOServiceGetMatchingService(main_port: MachPort, matching: CFDictionaryRef)
69
+            -> IoService;
70
+        fn IORegistryEntryCreateCFProperty(
71
+            entry: IoRegistryEntry,
72
+            key: CFStringRef,
73
+            allocator: CFAllocatorRef,
74
+            options: u32,
75
+        ) -> CFTypeRef;
76
+        fn IOObjectRelease(object: IoObject) -> KernReturn;
77
+    }
78
+
79
+    pub(super) fn hardware_model() -> Option<String> {
80
+        sysctl_string("hw.model")
81
+    }
82
+
83
+    pub(super) fn operating_system() -> Option<(String, String)> {
84
+        let plist = plist::from_file::<_, Dictionary>(SYSTEM_VERSION_PLIST).ok()?;
85
+        let product_name = plist_string(&plist, "ProductName").unwrap_or("macOS");
86
+        let product_version = plist_string(&plist, "ProductVersion")?;
87
+        let build_version = plist_string(&plist, "ProductBuildVersion")?;
88
+
89
+        Some((
90
+            product_name.to_string(),
91
+            format!("{product_version};{build_version}"),
92
+        ))
93
+    }
94
+
95
+    pub(super) fn platform_uuid() -> Option<String> {
96
+        let matching =
97
+            unsafe { IOServiceMatching(K_IO_PLATFORM_EXPERT_DEVICE.as_ptr() as *const c_char) };
98
+        if matching.is_null() {
99
+            return None;
100
+        }
101
+
102
+        let service = unsafe {
103
+            IOServiceGetMatchingService(K_IO_MAIN_PORT_DEFAULT, matching as CFDictionaryRef)
104
+        };
105
+        if service == 0 {
106
+            return None;
107
+        }
108
+
109
+        let key = CFString::new(K_IO_PLATFORM_UUID);
110
+        let value = unsafe {
111
+            IORegistryEntryCreateCFProperty(
112
+                service,
113
+                key.as_concrete_TypeRef(),
114
+                kCFAllocatorDefault,
115
+                0,
116
+            )
117
+        };
118
+        unsafe {
119
+            IOObjectRelease(service);
120
+        }
121
+
122
+        if value.is_null() {
123
+            return None;
124
+        }
125
+
126
+        let value = unsafe { CFString::wrap_under_create_rule(value as CFStringRef) };
127
+        Some(value.to_string())
128
+    }
129
+
130
+    fn sysctl_string(name: &str) -> Option<String> {
131
+        let name = CString::new(name).ok()?;
132
+        let mut len: size_t = 0;
133
+        let len_result = unsafe {
134
+            libc::sysctlbyname(name.as_ptr(), ptr::null_mut(), &mut len, ptr::null_mut(), 0)
135
+        };
136
+        if len_result != 0 || len == 0 {
137
+            return None;
138
+        }
139
+
140
+        let mut value = vec![0u8; len];
141
+        let result = unsafe {
142
+            libc::sysctlbyname(
143
+                name.as_ptr(),
144
+                value.as_mut_ptr() as *mut c_void,
145
+                &mut len,
146
+                ptr::null_mut(),
147
+                0,
148
+            )
149
+        };
150
+        if result != 0 {
151
+            return None;
152
+        }
153
+
154
+        if value.last() == Some(&0) {
155
+            value.pop();
156
+        }
157
+        String::from_utf8(value).ok()
158
+    }
159
+
160
+    fn plist_string<'a>(plist: &'a Dictionary, key: &str) -> Option<&'a str> {
161
+        plist.get(key).and_then(Value::as_string)
162
+    }
163
+}
... ...
@@ -205,7 +205,7 @@ pub(crate) fn developer_account_title(role: &SharedString) -> SharedString {
205 205
 }
206 206
 
207 207
 pub(crate) fn app_identity(app: &AppOption) -> SharedString {
208
-    format!("{} - {} ({})", app.bundle_id, app.version, app.build).into()
208
+    format!("{} - {} ({})", app.bundle_id(), app.version(), app.build()).into()
209 209
 }
210 210
 
211 211
 pub(crate) fn device_identity(device: &DeviceOption) -> SharedString {
212 212