Dadoum commited on 2026-07-10 00:00:26
Showing 8 changed files, with 389 additions and 48 deletions.
| ... | ... |
@@ -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 |