Remove blocking from super-sideloader and add an indicator
Dadoum

Dadoum commited on 2026-07-10 00:00:26
Showing 8 changed files, with 389 additions and 48 deletions.

... ...
@@ -5982,9 +5982,7 @@ dependencies = [
5982 5982
  "base64",
5983 5983
  "bytes",
5984 5984
  "encoding_rs",
5985
- "futures-channel",
5986 5985
  "futures-core",
5987
- "futures-util",
5988 5986
  "h2",
5989 5987
  "http",
5990 5988
  "http-body",
... ...
@@ -26,7 +26,7 @@ log = "0.4.33"
26 26
 plist.workspace = true
27 27
 pem-rfc7468 = { version = "0.7", features = ["alloc"] }
28 28
 rand = "0.10.2"
29
-reqwest = { workspace = true, features = ["blocking"] }
29
+reqwest.workspace = true
30 30
 serde = { version = "1.0.228", features = ["derive"] }
31 31
 sha1.workspace = true
32 32
 thiserror.workspace = true
... ...
@@ -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="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>
3
+  <path d="M12 9v4"/>
4
+  <path d="M12 17h.01"/>
5
+</svg>
... ...
@@ -21,6 +21,33 @@ pub(crate) struct LoadedSideloaderState {
21 21
     pub(crate) should_save_preferences: bool,
22 22
 }
23 23
 
24
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25
+pub(crate) enum SideloadReadiness {
26
+    Ready,
27
+    NeedsAdi,
28
+    NeedsAccount,
29
+    NeedsTeam,
30
+    NeedsCertificate,
31
+    NeedsAppId,
32
+    NeedsApp,
33
+    NeedsDevice,
34
+}
35
+
36
+impl SideloadReadiness {
37
+    pub(crate) fn label(self) -> &'static str {
38
+        match self {
39
+            Self::Ready => "Ready",
40
+            Self::NeedsAdi => "Set up CoreADI",
41
+            Self::NeedsAccount => "Log in to your account",
42
+            Self::NeedsTeam => "Select a developer team",
43
+            Self::NeedsCertificate => "Set up a certificate",
44
+            Self::NeedsAppId => "Select an App ID",
45
+            Self::NeedsApp => "Select an IPA",
46
+            Self::NeedsDevice => "Connect a device",
47
+        }
48
+    }
49
+}
50
+
24 51
 #[derive(Debug)]
25 52
 pub(crate) struct SideloaderState {
26 53
     pub(crate) theme_preference: ThemePreference,
... ...
@@ -161,6 +188,41 @@ impl SideloaderState {
161 188
         self.device_selection.selected()
162 189
     }
163 190
 
191
+    pub(crate) fn sideload_readiness(&self) -> SideloadReadiness {
192
+        if !self
193
+            .adi_backends
194
+            .get(self.selected_adi_backend)
195
+            .is_some_and(|backend| backend.availability.is_ready())
196
+        {
197
+            return SideloadReadiness::NeedsAdi;
198
+        }
199
+        let Some(account) = self.selected_account() else {
200
+            return SideloadReadiness::NeedsAccount;
201
+        };
202
+        let Some(team) = account.teams.get(self.selected_team) else {
203
+            return SideloadReadiness::NeedsTeam;
204
+        };
205
+        let Some(certificate) = team.certificates.get(self.selected_certificate) else {
206
+            return SideloadReadiness::NeedsCertificate;
207
+        };
208
+        if !certificate.private_key_available
209
+            || certificate.certificate_fingerprint.is_none()
210
+            || certificate.public_key_fingerprint.is_none()
211
+        {
212
+            return SideloadReadiness::NeedsCertificate;
213
+        }
214
+        if !self.auto_app_id && team.app_ids.get(self.selected_app_id).is_none() {
215
+            return SideloadReadiness::NeedsAppId;
216
+        }
217
+        if self.selected_app().is_none() {
218
+            return SideloadReadiness::NeedsApp;
219
+        }
220
+        if self.selected_device().is_none() {
221
+            return SideloadReadiness::NeedsDevice;
222
+        }
223
+        SideloadReadiness::Ready
224
+    }
225
+
164 226
     pub(crate) fn select_team(&mut self, index: usize) -> Option<String> {
165 227
         let team_count = self.accounts.get(self.selected_account)?.teams.len();
166 228
         if index >= team_count {
... ...
@@ -558,9 +620,12 @@ fn random_android_adi_identifier() -> String {
558 620
 mod tests {
559 621
     use super::*;
560 622
     use crate::app::models::{
561
-        AdiBackendAvailability, AdiBackendDetail, AdiProvisioningState, AppIdOption,
562
-        DevelopmentCertificateOption,
623
+        AdiBackendAvailability, AdiBackendDetail, AdiProvisioningState, AppIdOption, AppMetadata,
624
+        AppOption, DevelopmentCertificateOption, DeviceOption, EntitlementsSource,
625
+        SupportedDeviceFamily,
563 626
     };
627
+    use crate::app::AppError;
628
+    use std::path::Path;
564 629
 
565 630
     fn app_id(identifier: &str) -> AppIdOption {
566 631
         AppIdOption {
... ...
@@ -624,6 +689,104 @@ mod tests {
624 689
         }
625 690
     }
626 691
 
692
+    fn empty_state() -> SideloaderState {
693
+        let identity = MachineIdentity {
694
+            machine_name: "Mac".to_string(),
695
+            os_name: "macOS".to_string(),
696
+            os_version: "15".to_string(),
697
+            machine_id: "machine-id".to_string(),
698
+        };
699
+        SideloaderState {
700
+            theme_preference: ThemePreference::System,
701
+            accounts: Vec::new(),
702
+            app_selection: AppSelection::default(),
703
+            device_selection: DeviceSelection::default(),
704
+            adi_backends: Vec::new(),
705
+            selected_account: 0,
706
+            selected_team: 0,
707
+            selected_certificate: 0,
708
+            auto_app_id: true,
709
+            selected_app_id: 0,
710
+            selected_adi_backend: 0,
711
+            machine_identity: identity.clone(),
712
+            android_device_identity: identity,
713
+            android_adi_identifier: "0123456789abcdef".to_string(),
714
+            enabled_patches: Vec::new(),
715
+            sideload_operation: SideloadOperation::Idle,
716
+        }
717
+    }
718
+
719
+    fn sample_app() -> AppOption {
720
+        AppOption {
721
+            metadata: AppMetadata::sample(
722
+                "Example",
723
+                "com.example.app",
724
+                "1.0",
725
+                "1",
726
+                "Example",
727
+                "16.0",
728
+                vec![SupportedDeviceFamily::IPhone],
729
+            ),
730
+            nested_bundles: Vec::new(),
731
+            path: "/tmp/Example.ipa".to_string(),
732
+            icon_path: None,
733
+            icon_override_path: None,
734
+            entitlements: Vec::new(),
735
+            entitlements_source: EntitlementsSource::GeneratedFallback,
736
+            entitlement_overrides: None,
737
+            patches: Vec::new(),
738
+        }
739
+    }
740
+
741
+    #[test]
742
+    fn sideload_readiness_reports_the_first_required_setup_step() {
743
+        let mut state = empty_state();
744
+        assert_eq!(state.sideload_readiness(), SideloadReadiness::NeedsAdi);
745
+
746
+        state.adi_backends = vec![backend(
747
+            AdiBackendKind::AndroidCoreAdi,
748
+            AdiBackendAvailability::Ready,
749
+        )];
750
+        assert_eq!(state.sideload_readiness(), SideloadReadiness::NeedsAccount);
751
+
752
+        state.accounts = vec![account("account", Vec::new())];
753
+        assert_eq!(state.sideload_readiness(), SideloadReadiness::NeedsTeam);
754
+
755
+        state.accounts[0].teams = vec![team("TEAM", Vec::new(), Vec::new())];
756
+        assert_eq!(
757
+            state.sideload_readiness(),
758
+            SideloadReadiness::NeedsCertificate
759
+        );
760
+
761
+        state.accounts[0].teams[0].certificates = vec![certificate("SERIAL")];
762
+        assert_eq!(state.sideload_readiness(), SideloadReadiness::NeedsApp);
763
+
764
+        state.app_selection.finish_loading(
765
+            Path::new("/tmp/Example.ipa"),
766
+            Ok::<_, AppError>(sample_app()),
767
+        );
768
+        assert_eq!(state.sideload_readiness(), SideloadReadiness::NeedsDevice);
769
+
770
+        let generation = state.device_selection.begin_refresh().unwrap();
771
+        state.device_selection.finish_refresh(
772
+            generation,
773
+            Ok(vec![DeviceOption {
774
+                name: "iPhone".to_string(),
775
+                model: "iPhone17,1".to_string(),
776
+                os: "iOS 19".to_string(),
777
+                udid: "device-udid".to_string(),
778
+                connection: "USB".to_string(),
779
+            }]),
780
+        );
781
+        assert_eq!(state.sideload_readiness(), SideloadReadiness::Ready);
782
+
783
+        state.accounts[0].teams[0].certificates[0].private_key_available = false;
784
+        assert_eq!(
785
+            state.sideload_readiness(),
786
+            SideloadReadiness::NeedsCertificate
787
+        );
788
+    }
789
+
627 790
     #[test]
628 791
     fn restores_developer_selection_from_preferences() {
629 792
         let accounts = vec![
... ...
@@ -241,3 +241,10 @@ fn only_shared_backend_runtime_builds_tokio_runtimes() {
241 241
         assert_absent(&module, &source, &forbidden);
242 242
     }
243 243
 }
244
+
245
+#[test]
246
+fn backend_modules_do_not_use_blocking_reqwest() {
247
+    for (module, source) in rust_files_under("src/backend") {
248
+        assert_absent(&module, &source, &["reqwest::blocking"]);
249
+    }
250
+}
... ...
@@ -16,9 +16,10 @@ use grandslam::http_session::AnisetteHTTPSession;
16 16
 #[cfg(target_os = "windows")]
17 17
 use ouroboros::self_referencing;
18 18
 use std::ffi::CString;
19
-use std::fs::{self, File};
20
-use std::io::{Read, Write};
19
+use std::fs;
21 20
 use std::path::{Path, PathBuf};
21
+use tokio::fs::File;
22
+use tokio::io::AsyncWriteExt as _;
22 23
 
23 24
 const ANDROID_COREADI_APK_URL: &str =
24 25
     "https://apps.mzstatic.com/content/android-apple-music-apk/applemusic.apk";
... ...
@@ -347,7 +348,10 @@ fn android_coreadi_backend(android_adi_identifier: &str) -> AdiBackend {
347 348
     };
348 349
 
349 350
     let library_path = android_coreadi_library_path();
350
-    let availability = if library_path.as_ref().is_some_and(|path| path.exists()) {
351
+    let availability = if library_path
352
+        .as_ref()
353
+        .is_some_and(|path| installed_library_exists(path))
354
+    {
351 355
         AdiBackendAvailability::Ready
352 356
     } else {
353 357
         AdiBackendAvailability::NeedsSetup
... ...
@@ -408,7 +412,7 @@ fn android_coreadi_provisioning_path() -> Option<PathBuf> {
408 412
     app_data_dir().map(|path| path.join("coreadi"))
409 413
 }
410 414
 
411
-pub(crate) fn download_android_coreadi_apk(
415
+pub(crate) async fn download_android_coreadi_apk(
412 416
     mut progress: impl FnMut(AndroidCoreAdiInstallProgress),
413 417
 ) -> BackendResult<PathBuf> {
414 418
     let _ = android_coreadi_abi().ok_or_else(|| {
... ...
@@ -421,76 +425,125 @@ pub(crate) fn download_android_coreadi_apk(
421 425
         BackendError::Unsupported("The application data folder is not available.".to_string())
422 426
     })?;
423 427
     if let Some(parent) = destination.parent() {
424
-        fs::create_dir_all(parent).map_err(|error| BackendError::Io {
428
+        tokio::fs::create_dir_all(parent)
429
+            .await
430
+            .map_err(|error| BackendError::Io {
425 431
                 action: "Create Apple Music APK folder",
426 432
                 path: parent.to_path_buf(),
427 433
                 source: error,
428 434
             })?;
429 435
     }
430 436
 
431
-    let mut response = reqwest::blocking::Client::new()
437
+    let mut response = reqwest::Client::new()
432 438
         .get(ANDROID_COREADI_APK_URL)
433 439
         .send()
440
+        .await
434 441
         .and_then(|response| response.error_for_status())
435 442
         .map_err(|error| {
436 443
             BackendError::Network(format!("Failed to download Apple Music APK: {error}"))
437 444
         })?;
438 445
     let total_bytes = response.content_length();
439
-    let mut file = File::create(&destination).map_err(|error| BackendError::Io {
446
+    let partial_destination = partial_file_path(&destination);
447
+    let mut file = File::create(&partial_destination)
448
+        .await
449
+        .map_err(|error| BackendError::Io {
440 450
             action: "Create Apple Music APK",
441
-        path: destination.clone(),
451
+            path: partial_destination.clone(),
442 452
             source: error,
443 453
         })?;
444 454
     let mut downloaded_bytes = 0;
445
-    let mut buffer = [0; 64 * 1024];
446 455
     progress(AndroidCoreAdiInstallProgress {
447 456
         downloaded_bytes,
448 457
         total_bytes,
449 458
     });
450 459
 
451
-    loop {
452
-        let read = response.read(&mut buffer).map_err(|error| {
460
+    while let Some(chunk) = response.chunk().await.map_err(|error| {
453 461
         BackendError::Network(format!("Failed to download Apple Music APK: {error}"))
454
-        })?;
455
-        if read == 0 {
456
-            break;
457
-        }
458
-
459
-        file.write_all(&buffer[..read])
462
+    })? {
463
+        file.write_all(&chunk)
464
+            .await
460 465
             .map_err(|error| BackendError::Io {
461 466
                 action: "Save Apple Music APK",
462
-                path: destination.clone(),
467
+                path: partial_destination.clone(),
463 468
                 source: error,
464 469
             })?;
465
-        downloaded_bytes += read as u64;
470
+        downloaded_bytes += chunk.len() as u64;
466 471
         progress(AndroidCoreAdiInstallProgress {
467 472
             downloaded_bytes,
468 473
             total_bytes,
469 474
         });
470 475
     }
471 476
 
472
-    file.flush().map_err(|error| BackendError::Io {
477
+    file.flush().await.map_err(|error| BackendError::Io {
473 478
         action: "Flush Apple Music APK",
474
-        path: destination.clone(),
479
+        path: partial_destination.clone(),
475 480
         source: error,
476 481
     })?;
482
+    drop(file);
477 483
 
478 484
     if downloaded_bytes == 0 {
485
+        let _ = tokio::fs::remove_file(&partial_destination).await;
479 486
         return Err(BackendError::Adi(
480 487
             "Downloaded Apple Music APK is empty.".to_string(),
481 488
         ));
482 489
     }
483 490
 
491
+    publish_partial_file(
492
+        &partial_destination,
493
+        &destination,
494
+        "Publish Apple Music APK",
495
+    )
496
+    .await?;
497
+
484 498
     Ok(destination)
485 499
 }
486 500
 
501
+fn installed_library_exists(path: &Path) -> bool {
502
+    fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
503
+}
504
+
505
+fn partial_file_path(destination: &Path) -> PathBuf {
506
+    let file_name = destination
507
+        .file_name()
508
+        .map(|name| name.to_string_lossy())
509
+        .unwrap_or_default();
510
+    destination.with_file_name(format!("{file_name}.part"))
511
+}
512
+
513
+async fn publish_partial_file(
514
+    partial: &Path,
515
+    destination: &Path,
516
+    action: &'static str,
517
+) -> BackendResult<()> {
518
+    match tokio::fs::remove_file(destination).await {
519
+        Ok(()) => {}
520
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
521
+        Err(source) => {
522
+            return Err(BackendError::Io {
523
+                action,
524
+                path: destination.to_path_buf(),
525
+                source,
526
+            });
527
+        }
528
+    }
529
+
530
+    tokio::fs::rename(partial, destination)
531
+        .await
532
+        .map_err(|source| BackendError::Io {
533
+            action,
534
+            path: destination.to_path_buf(),
535
+            source,
536
+        })
537
+}
538
+
487 539
 pub(crate) async fn download_and_install_android_coreadi(
488 540
     mut progress: impl FnMut(AndroidCoreAdiInstallEvent) + Send + 'static,
489 541
 ) -> BackendResult<PathBuf> {
490 542
     backend_runtime::run_send("CoreADI install", async move {
491 543
         let apk_path = download_android_coreadi_apk(|download| {
492 544
             progress(AndroidCoreAdiInstallEvent::Downloading(download));
493
-        })?;
545
+        })
546
+        .await?;
494 547
         progress(AndroidCoreAdiInstallEvent::Installing);
495 548
         install_android_coreadi_from_apk_async(apk_path).await
496 549
     })
... ...
@@ -540,17 +593,24 @@ async fn install_android_coreadi_from_apk_async(apk_path: PathBuf) -> BackendRes
540 593
         BackendError::Unsupported("The application data folder is not available.".to_string())
541 594
     })?;
542 595
     if let Some(parent) = destination.parent() {
543
-        fs::create_dir_all(parent).map_err(|error| BackendError::Io {
596
+        tokio::fs::create_dir_all(parent)
597
+            .await
598
+            .map_err(|error| BackendError::Io {
544 599
                 action: "Create CoreADI folder",
545 600
                 path: parent.to_path_buf(),
546 601
                 source: error,
547 602
             })?;
548 603
     }
549
-    let mut destination_file = File::create(&destination).map_err(|error| BackendError::Io {
604
+    let partial_destination = partial_file_path(&destination);
605
+    let mut destination_file =
606
+        File::create(&partial_destination)
607
+            .await
608
+            .map_err(|error| BackendError::Io {
550 609
                 action: "Create CoreADI destination",
551
-        path: destination.clone(),
610
+                path: partial_destination.clone(),
552 611
                 source: error,
553 612
             })?;
613
+    let mut written_bytes = 0u64;
554 614
     let mut buffer = [0u8; 64 * 1024];
555 615
     loop {
556 616
         let read = entry_reader.read(&mut buffer).await.map_err(|error| {
... ...
@@ -559,19 +619,36 @@ async fn install_android_coreadi_from_apk_async(apk_path: PathBuf) -> BackendRes
559 619
         if read == 0 {
560 620
             break;
561 621
         }
622
+
562 623
         destination_file
563 624
             .write_all(&buffer[..read])
625
+            .await
564 626
             .map_err(|error| BackendError::Io {
565 627
                 action: "Store CoreADI",
566
-                path: destination.clone(),
628
+                path: partial_destination.clone(),
567 629
                 source: error,
568 630
             })?;
631
+        written_bytes += read as u64;
569 632
     }
570
-    destination_file.flush().map_err(|error| BackendError::Io {
633
+
634
+    destination_file
635
+        .flush()
636
+        .await
637
+        .map_err(|error| BackendError::Io {
571 638
             action: "Flush CoreADI",
572
-        path: destination.clone(),
639
+            path: partial_destination.clone(),
573 640
             source: error,
574 641
         })?;
642
+    drop(destination_file);
643
+
644
+    if written_bytes == 0 {
645
+        let _ = tokio::fs::remove_file(&partial_destination).await;
646
+        return Err(BackendError::Zip(
647
+            "APK contains an empty CoreADI library.".to_string(),
648
+        ));
649
+    }
650
+
651
+    publish_partial_file(&partial_destination, &destination, "Publish CoreADI").await?;
575 652
 
576 653
     Ok(destination)
577 654
 }
... ...
@@ -671,4 +748,25 @@ mod tests {
671 748
                 .any(|backend| backend.kind == AdiBackendKind::AndroidCoreAdi));
672 749
         }
673 750
     }
751
+
752
+    #[test]
753
+    fn installed_library_requires_a_nonempty_regular_file() {
754
+        let temp = tempfile::tempdir().expect("temporary directory should be created");
755
+        let library = temp.path().join(ANDROID_COREADI_LIBRARY_FILE);
756
+
757
+        assert!(!installed_library_exists(&library));
758
+        fs::write(&library, []).expect("empty library should be created");
759
+        assert!(!installed_library_exists(&library));
760
+        fs::write(&library, [1]).expect("nonempty library should be created");
761
+        assert!(installed_library_exists(&library));
762
+        assert!(!installed_library_exists(temp.path()));
763
+    }
764
+
765
+    #[test]
766
+    fn partial_file_is_stored_next_to_its_destination() {
767
+        assert_eq!(
768
+            partial_file_path(Path::new("/data/x86_64/libCoreADI.so")),
769
+            PathBuf::from("/data/x86_64/libCoreADI.so.part")
770
+        );
771
+    }
674 772
 }
... ...
@@ -4,7 +4,7 @@ use crate::app::models::{
4 4
     SideloadOperation, SideloadPhase,
5 5
 };
6 6
 use crate::app::preferences::{apply_app_overrides, AppOverridePreferences};
7
-use crate::app::state::SideloaderState;
7
+use crate::app::state::{SideloadReadiness, SideloaderState};
8 8
 use crate::app::AppError;
9 9
 use crate::constants::*;
10 10
 use crate::ui::settings::{show_or_open_settings_window, SettingsMode, SettingsWindowHandle};
... ...
@@ -2017,12 +2017,42 @@ fn developer_team_count(account: &AccountOption) -> String {
2017 2017
     }
2018 2018
 }
2019 2019
 
2020
-fn sideload_top_label(operation: &SideloadOperation) -> &'static str {
2021
-    match operation {
2022
-        SideloadOperation::Idle => "Draft",
2023
-        SideloadOperation::Running { phase, .. } => sideload_phase_label(*phase),
2024
-        SideloadOperation::Finished { .. } => "Done",
2025
-        SideloadOperation::Failed { .. } => "Failed",
2020
+struct HeaderStatus {
2021
+    label: &'static str,
2022
+    color: u32,
2023
+    icon: &'static str,
2024
+}
2025
+
2026
+fn header_status(state: &SideloaderState) -> HeaderStatus {
2027
+    if let SideloadOperation::Running { phase, .. } = &state.sideload_operation {
2028
+        return HeaderStatus {
2029
+            label: sideload_phase_label(*phase),
2030
+            color: 0x173f45,
2031
+            icon: "icons/refresh-cw.svg",
2032
+        };
2033
+    }
2034
+
2035
+    let readiness = state.sideload_readiness();
2036
+    if readiness != SideloadReadiness::Ready {
2037
+        return HeaderStatus {
2038
+            label: readiness.label(),
2039
+            color: 0x9a5b13,
2040
+            icon: "icons/triangle-alert.svg",
2041
+        };
2042
+    }
2043
+
2044
+    match &state.sideload_operation {
2045
+        SideloadOperation::Failed { .. } => HeaderStatus {
2046
+            label: "Action failed",
2047
+            color: 0x9a302b,
2048
+            icon: "icons/triangle-alert.svg",
2049
+        },
2050
+        SideloadOperation::Idle | SideloadOperation::Finished { .. } => HeaderStatus {
2051
+            label: readiness.label(),
2052
+            color: 0x1d6b45,
2053
+            icon: "icons/check.svg",
2054
+        },
2055
+        SideloadOperation::Running { .. } => unreachable!("running status returned above"),
2026 2056
     }
2027 2057
 }
2028 2058
 
... ...
@@ -2185,7 +2215,7 @@ impl Render for SideloaderView {
2185 2215
             self.spinner_turns = 0.;
2186 2216
         }
2187 2217
 
2188
-        let status_color = sideload_status_color(&self.sideload_operation);
2218
+        let header_status = header_status(&self.state);
2189 2219
 
2190 2220
         div()
2191 2221
             .track_focus(&self.focus_handle)
... ...
@@ -2255,13 +2285,17 @@ impl Render for SideloaderView {
2255 2285
                                     .flex()
2256 2286
                                     .items_center()
2257 2287
                                     .gap_2()
2258
-                                    .child(div().w_2().h_2().rounded_full().bg(rgb(status_color)))
2288
+                                    .child(lucide_icon_tinted(
2289
+                                        header_status.icon,
2290
+                                        header_status.color,
2291
+                                    ))
2259 2292
                                     .child(
2260 2293
                                         div()
2261 2294
                                             .text_sm()
2262 2295
                                             .font_weight(FontWeight::SEMIBOLD)
2263
-                                            .text_color(rgb(status_color))
2264
-                                            .child(sideload_top_label(&self.sideload_operation)),
2296
+                                            .text_color(rgb(header_status.color))
2297
+                                            .whitespace_normal()
2298
+                                            .child(header_status.label),
2265 2299
                                     ),
2266 2300
                             )
2267 2301
                             .child(
... ...
@@ -1,4 +1,5 @@
1 1
 use super::*;
2
+use crate::app::models::AdiBackendAvailability;
2 3
 
3 4
 pub(super) struct AdiViewProps<'a> {
4 5
     pub(super) focus_handle: &'a FocusHandle,
... ...
@@ -517,10 +518,7 @@ fn adi_backend_status_section(
517 518
                     },
518 519
                 )
519 520
                 .when(
520
-                    matches!(
521
-                        &backend.provisioning_state,
522
-                        AdiProvisioningState::NotProvisioned | AdiProvisioningState::Error(_)
523
-                    ),
521
+                    should_show_provision_action(backend.availability, &backend.provisioning_state),
524 522
                     |this| {
525 523
                         this.child(adi_provision_button(
526 524
                             provisioning_operation,
... ...
@@ -548,6 +546,17 @@ fn adi_backend_status_section(
548 546
         )
549 547
 }
550 548
 
549
+fn should_show_provision_action(
550
+    availability: AdiBackendAvailability,
551
+    provisioning_state: &AdiProvisioningState,
552
+) -> bool {
553
+    availability.is_ready()
554
+        && matches!(
555
+            provisioning_state,
556
+            AdiProvisioningState::NotProvisioned | AdiProvisioningState::Error(_)
557
+        )
558
+}
559
+
551 560
 fn adi_provision_button(
552 561
     provisioning_operation: Option<&AdiOperationState>,
553 562
     spinner_turns: f32,
... ...
@@ -936,3 +945,30 @@ fn read_only_badge() -> gpui::Div {
936 945
         .justify_center()
937 946
         .child("Read-only")
938 947
 }
948
+
949
+#[cfg(test)]
950
+mod tests {
951
+    use super::*;
952
+
953
+    #[test]
954
+    fn provisioning_action_requires_a_ready_backend() {
955
+        let error = AdiProvisioningState::Error("installation failed".to_string());
956
+
957
+        assert!(!should_show_provision_action(
958
+            AdiBackendAvailability::NeedsSetup,
959
+            &error
960
+        ));
961
+        assert!(should_show_provision_action(
962
+            AdiBackendAvailability::Ready,
963
+            &error
964
+        ));
965
+        assert!(should_show_provision_action(
966
+            AdiBackendAvailability::Ready,
967
+            &AdiProvisioningState::NotProvisioned
968
+        ));
969
+        assert!(!should_show_provision_action(
970
+            AdiBackendAvailability::Ready,
971
+            &AdiProvisioningState::Provisioned
972
+        ));
973
+    }
974
+}
939 975