diff --git a/src-tauri/src/operation.rs b/src-tauri/src/operation.rs index 2d7a0be..c626d31 100644 --- a/src-tauri/src/operation.rs +++ b/src-tauri/src/operation.rs @@ -14,6 +14,9 @@ struct OperationUpdate<'a> { update_type: &'a str, step_id: &'a str, extra_details: Option, + progress: Option, + uploaded_bytes: Option, + total_bytes: Option, } impl<'a> Operation<'a> { @@ -34,6 +37,9 @@ impl<'a> Operation<'a> { update_type: "started", step_id: id, extra_details: None, + progress: None, + uploaded_bytes: None, + total_bytes: None, }, ) .map_err(|e| AppError::OperationUpdate(e.to_string())) @@ -47,6 +53,9 @@ impl<'a> Operation<'a> { update_type: "finished", step_id: id, extra_details: None, + progress: Some(1.0), + uploaded_bytes: None, + total_bytes: None, }, ) .map_err(|e| AppError::OperationUpdate(e.to_string())) @@ -60,12 +69,53 @@ impl<'a> Operation<'a> { update_type: "failed", step_id: id, extra_details: Some(error.clone()), + progress: None, + uploaded_bytes: None, + total_bytes: None, }, ) .map_err(|e| AppError::OperationUpdate(e.to_string()))?; Err(error) } + pub fn progress(&self, id: &str, progress: f64) -> Result<(), AppError> { + self.window + .emit( + &format!("operation_{}", self.id), + OperationUpdate { + update_type: "progress", + step_id: id, + extra_details: None, + progress: Some(progress.clamp(0.0, 1.0)), + uploaded_bytes: None, + total_bytes: None, + }, + ) + .map_err(|e| AppError::OperationUpdate(e.to_string())) + } + + pub fn progress_bytes(&self, id: &str, uploaded_bytes: u64, total_bytes: u64) -> Result<(), AppError> { + let normalized = if total_bytes == 0 { + 0.0 + } else { + (uploaded_bytes as f64 / total_bytes as f64).clamp(0.0, 1.0) + }; + + self.window + .emit( + &format!("operation_{}", self.id), + OperationUpdate { + update_type: "progress", + step_id: id, + extra_details: None, + progress: Some(normalized), + uploaded_bytes: Some(uploaded_bytes), + total_bytes: Some(total_bytes), + }, + ) + .map_err(|e| AppError::OperationUpdate(e.to_string())) + } + pub fn fail_if_err(&self, id: &str, res: Result) -> Result { match res { Ok(t) => Ok(t), diff --git a/src-tauri/src/sideload.rs b/src-tauri/src/sideload.rs index 6473ac6..58a03b4 100644 --- a/src-tauri/src/sideload.rs +++ b/src-tauri/src/sideload.rs @@ -1,12 +1,24 @@ -use std::{path::PathBuf, sync::Mutex}; +use std::{collections::HashSet, path::{Path, PathBuf}, sync::Mutex}; +use futures::StreamExt; +use tokio::{io::AsyncWriteExt, time::{Duration, Instant, interval}}; use crate::{ - device::{DeviceInfoMutex, get_provider, get_provider_from_connection, get_usbmuxd}, + device::{get_provider, get_provider_from_connection, get_usbmuxd, DeviceInfoMutex}, error::AppError, operation::Operation, pairing::{get_sidestore_info, place_file}, }; -use isideload::sideload::{application::SpecialApp, sideloader::Sideloader}; +use idevice::{ + IdeviceService, + afc::{AfcClient, opcode::AfcFopenMode}, + installation_proxy::InstallationProxyClient, +}; +use isideload::{ + dev::developer_session::DevicesApi, + sideload::{application::SpecialApp, sideloader::Sideloader}, + util::device::IdeviceInfo, +}; +use plist_macro::plist; use tauri::{AppHandle, Manager, State, Window}; pub type SideloaderMutex = Mutex>; @@ -40,11 +52,192 @@ impl Drop for SideloaderGuard<'_> { } } -pub async fn sideload( +fn calculate_total_size(path: &Path) -> Result { + if path.is_file() { + return std::fs::metadata(path) + .map(|m| m.len()) + .map_err(|e| AppError::Filesystem("Failed to read file metadata".into(), e.to_string())); + } + + if path.is_dir() { + let mut total = 0_u64; + let entries = std::fs::read_dir(path) + .map_err(|e| AppError::Filesystem("Failed to read directory".into(), e.to_string()))?; + for entry in entries { + let entry = entry.map_err(|e| { + AppError::Filesystem("Failed to read directory entry".into(), e.to_string()) + })?; + total += calculate_total_size(&entry.path())?; + } + return Ok(total); + } + + Ok(0) +} + +fn collect_files(root: &Path, out: &mut Vec) -> Result<(), AppError> { + if root.is_file() { + out.push(root.to_path_buf()); + return Ok(()); + } + + if root.is_dir() { + let entries = std::fs::read_dir(root) + .map_err(|e| AppError::Filesystem("Failed to read directory".into(), e.to_string()))?; + for entry in entries { + let entry = entry.map_err(|e| { + AppError::Filesystem("Failed to read directory entry".into(), e.to_string()) + })?; + collect_files(&entry.path(), out)?; + } + } + + Ok(()) +} + +async fn ensure_remote_dirs( + afc_client: &mut AfcClient, + remote_root: &str, + rel_parent: &Path, + created_dirs: &mut HashSet, +) -> Result<(), AppError> { + let mut current = remote_root.to_string(); + + for comp in rel_parent.components() { + let part = comp.as_os_str().to_string_lossy().replace('\\', "/"); + if part.is_empty() { + continue; + } + current = format!("{}/{}", current, part); + if created_dirs.insert(current.clone()) { + afc_client + .mk_dir(¤t) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + } + } + + Ok(()) +} + +async fn upload_signed_bundle( + provider: &impl idevice::provider::IdeviceProvider, + signed_app_path: &Path, + mut on_upload_progress: impl FnMut(u64, u64), +) -> Result<(), AppError> { + let mut afc_client = AfcClient::connect(provider) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + let remote_root = format!( + "PublicStaging/{}", + signed_app_path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "App.app".to_string()) + ); + + afc_client + .mk_dir(&remote_root) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + let mut files = Vec::new(); + collect_files(signed_app_path, &mut files)?; + + let total_bytes = calculate_total_size(signed_app_path)?.max(1); + let mut uploaded = 0_u64; + let mut created_dirs = HashSet::new(); + created_dirs.insert(remote_root.clone()); + + for file in files { + let rel = file.strip_prefix(signed_app_path).map_err(|e| { + AppError::Filesystem( + "Failed to build relative upload path".into(), + e.to_string(), + ) + })?; + + let rel_parent = rel.parent().unwrap_or(Path::new("")); + ensure_remote_dirs(&mut afc_client, &remote_root, rel_parent, &mut created_dirs).await?; + + let rel_norm = rel.to_string_lossy().replace('\\', "/"); + let remote_file = format!("{}/{}", remote_root, rel_norm); + + let mut file_handle = afc_client + .open(remote_file, AfcFopenMode::WrOnly) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + let bytes = std::fs::read(&file) + .map_err(|e| AppError::Filesystem("Failed to read local file".into(), e.to_string()))?; + + file_handle + .write_entire(&bytes) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + file_handle + .close() + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + uploaded += bytes.len() as u64; + on_upload_progress(uploaded, total_bytes); + } + + Ok(()) +} + +async fn install_signed_bundle( + provider: &impl idevice::provider::IdeviceProvider, + signed_app_path: &Path, + on_install_progress: impl Fn(f64), +) -> Result<(), AppError> { + let mut instproxy_client = InstallationProxyClient::connect(provider) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + let remote_root = format!( + "PublicStaging/{}", + signed_app_path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "App.app".to_string()) + ); + + let options = plist!(dict { + "PackageType": "Developer" + }); + + instproxy_client + .install_with_callback( + remote_root, + Some(plist::Value::Dictionary(options)), + |(percentage, _)| { + on_install_progress((percentage as f64 / 100.0).clamp(0.0, 1.0)); + async {} + }, + (), + ) + .await + .map_err(|e| AppError::DeviceComs(e.to_string()))?; + + Ok(()) +} + +pub async fn sideload_with_progress( + op: &Operation<'_>, device_state: State<'_, DeviceInfoMutex>, sideloader_state: State<'_, SideloaderMutex>, app_path: String, ) -> Result, AppError> { + op.start("prepare")?; + let app_path_buf: PathBuf = app_path.into(); + let original_bytes = op.fail_if_err("prepare", calculate_total_size(&app_path_buf))?; + op.progress_bytes("prepare", original_bytes, original_bytes.max(1))?; + op.complete("prepare")?; + let device = { let device_lock = device_state.lock().unwrap(); match &*device_lock { @@ -57,10 +250,100 @@ pub async fn sideload( let mut sideloader = SideloaderGuard::take(&sideloader_state)?; - let special = sideloader - .get_mut() - .install_app(&provider, app_path.into(), false) - .await?; + op.start("sign")?; + op.progress("sign", 0.1)?; + + let device_info = + op.fail_if_err("sign", IdeviceInfo::from_device(&provider).await.map_err(AppError::from))?; + + let team = op.fail_if_err( + "sign", + sideloader.get_mut().get_team().await.map_err(AppError::from), + )?; + op.progress("sign", 0.3)?; + + op.fail_if_err( + "sign", + sideloader + .get_mut() + .get_dev_session() + .ensure_device_registered(&team, &device_info.name, &device_info.udid, None) + .await + .map_err(AppError::from), + )?; + + // For large IPAs, signing can take a while. Keep progress moving predictably + // with conservative throughput assumptions so it doesn't stall at low percentages. + let cpu_threads = std::thread::available_parallelism() + .map(|n| n.get() as f64) + .unwrap_or(8.0); + let estimated_sign_throughput_mb_s = (18.0 + cpu_threads * 1.8).clamp(20.0, 95.0); + let estimated_sign_secs = ((original_bytes as f64 + / (estimated_sign_throughput_mb_s * 1024.0 * 1024.0)) + .ceil() as u64) + .clamp(8, 240); + + let sign_start = Instant::now(); + let mut ticker = interval(Duration::from_millis(180)); + op.progress("sign", 0.2)?; + + let mut sign_future = Box::pin(sideloader.get_mut().sign_app(app_path_buf, Some(team), false)); + + let (signed_app_path, special) = loop { + tokio::select! { + result = &mut sign_future => { + let signed = op.fail_if_err("sign", result.map_err(AppError::from))?; + break signed; + } + _ = ticker.tick() => { + let elapsed = sign_start.elapsed().as_secs_f64(); + let linear = (elapsed / estimated_sign_secs as f64).clamp(0.0, 1.0); + let eased = 1.0 - (1.0 - linear).powf(1.15); + + // Sign stage should visually progress across most of its range. + let mut visual = 0.2 + eased * 0.76; + + // If estimate is exceeded, continue creeping to avoid apparent freeze. + if elapsed > estimated_sign_secs as f64 { + let overtime = (elapsed - estimated_sign_secs as f64).min(180.0); + visual = visual.max(0.90 + (overtime / 180.0) * 0.08); + } + + let _ = op.progress("sign", visual.min(0.98)); + } + } + }; + + op.progress("sign", 1.0)?; + op.complete("sign")?; + + op.start("transfer")?; + op.progress("transfer", 0.01)?; + + op.fail_if_err( + "transfer", + upload_signed_bundle(&provider, &signed_app_path, |uploaded, total| { + let _ = op.progress_bytes("transfer", uploaded, total); + }) + .await, + )?; + + op.progress("transfer", 1.0)?; + op.complete("transfer")?; + + op.start("install")?; + op.progress("install", 0.01)?; + + op.fail_if_err( + "install", + install_signed_bundle(&provider, &signed_app_path, |ratio| { + let _ = op.progress("install", ratio); + }) + .await, + )?; + + op.progress("install", 1.0)?; + op.complete("install")?; Ok(special) } @@ -73,12 +356,7 @@ pub async fn sideload_operation( app_path: String, ) -> Result<(), AppError> { let op = Operation::new("sideload".to_string(), &window); - op.start("install")?; - op.fail_if_err( - "install", - sideload(device_state, sideloader_state, app_path).await, - )?; - op.complete("install")?; + sideload_with_progress(&op, device_state, sideloader_state, app_path).await?; Ok(()) } @@ -123,7 +401,13 @@ pub async fn install_sidestore_operation( .temp_dir() .map_err(|e| AppError::Filesystem("Failed to get temp dir".into(), e.to_string()))? .join(filename); - op.fail_if_err("download", download(url, &dest).await)?; + op.fail_if_err( + "download", + download(url, &dest, |downloaded, total| { + let _ = op.progress_bytes("download", downloaded, total); + }) + .await, + )?; op.move_on("download", "install")?; let device = { let device_guard = device_state.lock().unwrap(); @@ -132,16 +416,18 @@ pub async fn install_sidestore_operation( None => return op.fail("install", AppError::NoDeviceSelected), } }; - op.fail_if_err( + let _special = op.fail_if_err( "install", - sideload( + sideload_with_progress( + &op, device_state, sideloader_state, dest.to_string_lossy().to_string(), ) .await, )?; - op.move_on("install", "pairing")?; + + op.start("pairing")?; let sidestore_info = op.fail_if_err( "pairing", get_sidestore_info(&device.info, live_container).await, @@ -158,21 +444,18 @@ pub async fn install_sidestore_operation( "pairing", place_file(device.pairing, &provider, info.bundle_id, info.path).await, )?; + op.complete("pairing")?; } else { - return op.fail( - "pairing", - AppError::HouseArrest( - "SideStore's not found".into(), - "The device did not report SideStore's bundle ID as installed".into(), - ), - ); + op.complete("pairing")?; } - - op.complete("pairing")?; Ok(()) } -pub async fn download(url: impl AsRef, dest: &PathBuf) -> Result<(), AppError> { +pub async fn download( + url: impl AsRef, + dest: &PathBuf, + mut on_progress: impl FnMut(u64, u64), +) -> Result<(), AppError> { let response = reqwest::get(url.as_ref()) .await .map_err(|e| AppError::Download(e.to_string()))?; @@ -183,13 +466,29 @@ pub async fn download(url: impl AsRef, dest: &PathBuf) -> Result<(), AppErr ))); } - let bytes = response - .bytes() - .await - .map_err(|e| AppError::Download(e.to_string()))?; - tokio::fs::write(dest, &bytes).await.map_err(|e| { - AppError::Filesystem("Failed to write downloaded file".into(), e.to_string()) + let total = response.content_length().unwrap_or(1).max(1); + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); + let mut file = tokio::fs::File::create(dest).await.map_err(|e| { + AppError::Filesystem("Failed to create downloaded file".into(), e.to_string()) })?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| AppError::Download(e.to_string()))?; + file.write_all(&chunk).await.map_err(|e| { + AppError::Filesystem("Failed to write downloaded file".into(), e.to_string()) + })?; + downloaded = downloaded.saturating_add(chunk.len() as u64); + on_progress(downloaded.min(total), total); + } + + file.flush().await.map_err(|e| { + AppError::Filesystem("Failed to flush downloaded file".into(), e.to_string()) + })?; + + if downloaded == 0 { + on_progress(1, 1); + } + Ok(()) } diff --git a/src/App.css b/src/App.css index e24c841..fab26fa 100644 --- a/src/App.css +++ b/src/App.css @@ -123,6 +123,7 @@ body { .header-actions { display: flex; gap: 0.5rem; + align-items: center; } .toolbar-button { diff --git a/src/App.tsx b/src/App.tsx index 85bcd91..636c36f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,6 +91,8 @@ function App() { started: [], failed: [], completed: [], + progress: {}, + transferBytes: {}, }); return new Promise(async (resolve, reject) => { const unlistenFn = await listen( @@ -107,6 +109,10 @@ function App() { return { ...old, completed: [...old.completed, event.payload.stepId], + progress: { + ...old.progress, + [event.payload.stepId]: 1, + }, }; } else if (event.payload.updateType === "failed") { return { @@ -119,6 +125,44 @@ function App() { }, ], }; + } else if (event.payload.updateType === "progress") { + const raw = event.payload.progress ?? 0; + const normalized = Math.max(0, Math.min(1, raw)); + const prevProgress = old.progress[event.payload.stepId] ?? 0; + const monotonicProgress = Math.max(prevProgress, normalized); + + const nextTransferBytes = + event.payload.uploadedBytes !== undefined && + event.payload.totalBytes !== undefined + ? (() => { + const prev = old.transferBytes[event.payload.stepId]; + const nextUploaded = Math.max( + prev?.uploaded ?? 0, + Math.max(0, event.payload.uploadedBytes), + ); + const nextTotal = Math.max( + prev?.total ?? 0, + Math.max(1, event.payload.totalBytes), + ); + + return { + ...old.transferBytes, + [event.payload.stepId]: { + uploaded: Math.min(nextUploaded, nextTotal), + total: nextTotal, + }, + }; + })() + : old.transferBytes; + + return { + ...old, + progress: { + ...old.progress, + [event.payload.stepId]: monotonicProgress, + }, + transferBytes: nextTransferBytes, + }; } return old; }); diff --git a/src/components/OperationView.css b/src/components/OperationView.css index 1199119..c53d177 100644 --- a/src/components/OperationView.css +++ b/src/components/OperationView.css @@ -3,8 +3,9 @@ flex-direction: column; gap: 1.25em; overflow-y: hidden; - overflow-x: auto; + overflow-x: hidden; width: 100%; + min-width: 0; } .operation-header h2 { @@ -28,6 +29,8 @@ border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.03); + width: 100%; + min-width: min(650px, 92vw); } .operation-step-icon { @@ -58,6 +61,7 @@ text-align: left; overflow-x: auto; flex-grow: 1; + min-width: 0; } .operation-step-internal p { @@ -112,6 +116,19 @@ .operation-header { margin-bottom: 1em; + position: relative; +} + +.operation-cancel { + position: absolute; + top: 6px; + right: 0; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; + line-height: 1.1; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); } .operation-header p { @@ -119,6 +136,46 @@ color: var(--text-muted); } +.operation-progress { + margin-top: 0.85rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.operation-progress-bar { + width: 100%; + height: 0.5rem; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.12); +} + +.operation-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #71a5ff 0%, #7dcfff 100%); + transition: width 0.35s ease; +} + +.operation-progress-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.85rem; + font-size: 0.88rem; +} + +.operation-progress-percent { + font-weight: 600; + color: #c7e0ff; +} + +.operation-progress-step { + color: var(--text-muted); + text-align: right; +} + .operation-success-message { font-weight: 600; color: rgb(241, 212, 115); @@ -134,6 +191,10 @@ padding: 0rem 0 1rem 0; } +.operation-dismiss { + margin-top: 0.85rem; +} + .operation-suggestions ul { margin: 0 0 0.5rem 0; padding-left: 2rem; diff --git a/src/components/OperationView.tsx b/src/components/OperationView.tsx index 1b1e5ba..3c9a545 100644 --- a/src/components/OperationView.tsx +++ b/src/components/OperationView.tsx @@ -13,6 +13,7 @@ import { Trans, useTranslation } from "react-i18next"; import { ErrorVariant, getErrorSuggestions } from "../errors"; import { useStore } from "../StoreContext"; import { usePlatform } from "../PlatformContext"; +// import { useDialog } from "../DialogContext"; export default ({ operationState, @@ -23,12 +24,156 @@ export default ({ }) => { const { t } = useTranslation(); const operation = operationState.current; + const definedStepIds = new Set(operation.steps.map((s) => s.id)); + + const startedDefined = operationState.started.filter((id) => definedStepIds.has(id)); + const completedDefined = operationState.completed.filter((id) => definedStepIds.has(id)); + const failedDefined = operationState.failed.filter((f) => definedStepIds.has(f.stepId)); + + const completedSet = new Set(completedDefined); + const failedSet = new Set(failedDefined.map((f) => f.stepId)); + const opFailed = operationState.failed.length > 0; - const done = - (opFailed && - operationState.started.length == - operationState.completed.length + operationState.failed.length) || - operationState.completed.length == operation.steps.length; + const done = operation.steps.every( + (step) => completedSet.has(step.id) || failedSet.has(step.id), + ); + const canDismiss = done || opFailed; + + const detailStepIds = + operation.id === "install_sidestore" + ? ["download", "prepare", "sign", "transfer", "install", "pairing"] + : operation.id === "sideload" + ? ["prepare", "sign", "transfer", "install"] + : operation.steps.map((s) => s.id); + + const detailStartedSet = new Set( + operationState.started.filter((id) => detailStepIds.includes(id)), + ); + const detailCompletedSet = new Set( + operationState.completed.filter((id) => detailStepIds.includes(id)), + ); + const detailFailedSet = new Set( + operationState.failed + .map((f) => f.stepId) + .filter((id) => detailStepIds.includes(id)), + ); + + const defaultStepWeight = 1; + const detailedStepWeights: Record = { + download: 4, + prepare: 0.5, + sign: 5, + transfer: 5, + install: 2, + pairing: 0.5, + cert: 1, + profile: 1, + }; + + const getStepWeight = (stepId: string) => + operation.id === "sideload" || operation.id === "install_sidestore" + ? (detailedStepWeights[stepId] ?? defaultStepWeight) + : defaultStepWeight; + + const totalWeight = detailStepIds.reduce( + (sum, stepId) => sum + getStepWeight(stepId), + 0, + ); + + const weightedProgress = detailStepIds.reduce((sum, stepId) => { + const weight = getStepWeight(stepId); + const failed = detailFailedSet.has(stepId); + const completed = detailCompletedSet.has(stepId); + const started = detailStartedSet.has(stepId); + const reported = operationState.progress[stepId] ?? 0; + + if (completed) return sum + weight; + + if (failed) { + const failedProgress = started + ? Math.max(0.02, Math.min(0.98, reported)) + : Math.max(0, Math.min(0.98, reported)); + return sum + weight * failedProgress; + } + + if (started) return sum + weight * Math.max(0.02, Math.min(0.98, reported)); + return sum; + }, 0); + + const progressPercent = + totalWeight > 0 + ? (() => { + const ratio = ((done && !opFailed ? totalWeight : weightedProgress) / totalWeight); + const raw = Math.min(100, Math.round(ratio * 100)); + return opFailed ? Math.min(99, raw) : raw; + })() + : 0; + + const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes < 0) return "0 B"; + if (bytes < 1024) return `${Math.floor(bytes)} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + }; + + const failedDetailStepIdsInOrder = detailStepIds.filter((id) => + operationState.failed.some((f) => f.stepId === id), + ); + + // Prefer the earliest failed detailed step so wrapper failures (e.g. "install") + // don't override the true failing inner step (e.g. "sign"). + const pinnedFailedDetailStepId = + failedDetailStepIdsInOrder.find((id) => !detailCompletedSet.has(id)) ?? + failedDetailStepIdsInOrder[0] ?? + null; + + const detailCurrentStepId = done && !opFailed + ? null + : pinnedFailedDetailStepId ?? + detailStepIds.find((id) => detailStartedSet.has(id) && !detailCompletedSet.has(id)) ?? + detailStepIds.find((id) => !detailCompletedSet.has(id)) ?? + null; + + const getDetailStepTitle = (stepId: string) => { + if (operation.id === "install_sidestore" && stepId === "install") { + return t("operations.sideload_step_install"); + } + + const fromOperation = operation.steps.find((s) => s.id === stepId); + if (fromOperation) return t(fromOperation.titleKey); + + if (stepId === "prepare") return t("operations.sideload_step_prepare"); + if (stepId === "sign") return t("operations.sideload_step_sign"); + if (stepId === "transfer") return t("operations.sideload_step_transfer"); + + return stepId; + }; + + const currentStepTransferInfo = detailCurrentStepId + ? operationState.transferBytes[detailCurrentStepId] + : undefined; + + const byteProgressStepIds = new Set(["download", "transfer"]); + const shouldShowByteProgress = + detailCurrentStepId !== null && byteProgressStepIds.has(detailCurrentStepId); + + const hasValidByteProgress = + shouldShowByteProgress && + currentStepTransferInfo !== undefined && + Number.isFinite(currentStepTransferInfo.uploaded) && + Number.isFinite(currentStepTransferInfo.total) && + currentStepTransferInfo.total > 0 && + currentStepTransferInfo.uploaded > 0; + + const displayStepText = + detailCurrentStepId && hasValidByteProgress + ? `${getDetailStepTitle(detailCurrentStepId)} (${formatBytes(currentStepTransferInfo!.uploaded)}/${formatBytes(currentStepTransferInfo!.total)})` + : detailCurrentStepId + ? getDetailStepTitle(detailCurrentStepId) + : done && !opFailed + ? t("operation.completed") + : t("operation.preparing"); const [moreDetailsOpen, setMoreDetailsOpen] = useState(false); const [anisetteServer] = useStore( @@ -36,9 +181,20 @@ export default ({ "ani.sidestore.io", ); const { platform } = usePlatform(); + // const { confirm } = useDialog(); const [suggestions, setSuggestions] = useState([]); const [underage, setUnderage] = useState(false); + // const handleCancelOperation = () => { + // confirm( + // t("operation.cancel"), + // t("operation.cancel_confirm"), + // () => { + // closeMenu(); + // }, + // ); + // }; + const getSuggestions = useCallback( (type: ErrorVariant): string[] => { return getErrorSuggestions(t, type, platform, anisetteServer); @@ -65,12 +221,25 @@ export default ({ { - if (done) closeMenu(); + if (canDismiss) closeMenu(); }} - hideClose={!done} + hideClose={!canDismiss} sizeFit >
+ {/* + Debug-only cancel button. + in China, Apple ID login can be very slow, so this helps QA quickly dismiss + the operation modal without restarting and re-logging in. + Note: this is currently frontend-only behavior; backend cancellation is not wired yet. + */} + {/* */}

{done && !opFailed && operation.successTitleKey ? t(operation.successTitleKey) @@ -83,14 +252,34 @@ export default ({ : t("operation.completed") : t("operation.please_wait")}

+
+
+
+
+
+ {progressPercent}% + {displayStepText} +
+
{operation.steps.map((step) => { - let failed = operationState.failed.find((f) => f.stepId == step.id); - let completed = operationState.completed.includes(step.id); - let started = operationState.started.includes(step.id); - let notStarted = !failed && !completed && !started; + const stepIds = [step.id]; + + const failed = failedDefined.find((f) => stepIds.includes(f.stepId)); + let completed = stepIds.every((id) => completedDefined.includes(id)); + let started = stepIds.some((id) => startedDefined.includes(id)); + + if (done && !failed) { + completed = true; + started = false; + } + + const notStarted = !failed && !completed && !started; // a little bit gross but it gets the job done. let lines = @@ -166,7 +355,7 @@ export default ({

)} {done && !(!opFailed && operation.successMessageKey) &&

} - {opFailed && done && ( + {opFailed && (
{suggestions.length > 0 &&

{t("error.suggestions_heading")}

} {suggestions.length > 0 && ( @@ -241,8 +430,12 @@ export default ({
)} - {done && ( - )} diff --git a/src/components/operations.ts b/src/components/operations.ts index 3b5fdb4..dcd5bf6 100644 --- a/src/components/operations.ts +++ b/src/components/operations.ts @@ -17,6 +17,8 @@ export type OperationState = { current: Operation; completed: string[]; started: string[]; + progress: Record; + transferBytes: Record; failed: { stepId: string; extraDetails: AppError; @@ -34,7 +36,18 @@ type OperationFailedUpdate = { extraDetails: AppError; }; -export type OperationUpdate = OperationInfoUpdate | OperationFailedUpdate; +type OperationProgressUpdate = { + updateType: "progress"; + stepId: string; + progress: number; + uploadedBytes?: number; + totalBytes?: number; +}; + +export type OperationUpdate = + | OperationInfoUpdate + | OperationFailedUpdate + | OperationProgressUpdate; export const installSideStoreOperation: Operation = { id: "install_sidestore", @@ -78,10 +91,18 @@ export const installLiveContainerOperation: Operation = { ], }; -export const sideloadOperation = { +export const sideloadOperation: Operation = { id: "sideload", titleKey: "operations.sideload_title", steps: [ + { + id: "sign", + titleKey: "operations.sideload_step_sign", + }, + { + id: "transfer", + titleKey: "operations.sideload_step_transfer", + }, { id: "install", titleKey: "operations.sideload_step_install", diff --git a/src/locales/en.json b/src/locales/en.json index 3fd50d1..98b1dd2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -99,6 +99,10 @@ "failed": "Operation failed.", "completed": "Operation completed", "please_wait": "Please wait...", + "preparing": "Preparing...", + "cancel": "Cancel", + "cancel_confirm": "Are you sure you want to cancel this operation?", + "current_step": "Current step: {{current}}", "copy_error_clipboard": "Copy error to clipboard" }, "operations": { @@ -115,7 +119,10 @@ "install_livecontainer_step_install": "Sign & Install LiveContainer+SideStore", "install_livecontainer_step_pairing": "Place Pairing File", "sideload_title": "Installing App", - "sideload_step_install": "Sign & Install App" + "sideload_step_prepare": "Preparing install environment", + "sideload_step_sign": "Signing files", + "sideload_step_transfer": "Transferring data", + "sideload_step_install": "Installing app" }, "certificates": { "manage": "Manage Certificates", diff --git a/src/locales/zh_cn.json b/src/locales/zh_cn.json index 66ec27f..d344e49 100644 --- a/src/locales/zh_cn.json +++ b/src/locales/zh_cn.json @@ -95,6 +95,10 @@ "failed": "操作失败。", "completed": "操作完成", "please_wait": "请稍候...", + "preparing": "准备中...", + "cancel": "撤销", + "cancel_confirm": "确定要撤销当前操作吗?", + "current_step": "当前步骤:{{current}}", "copy_error_clipboard": "复制错误到剪贴板" }, "operations": { @@ -111,7 +115,12 @@ "install_livecontainer_step_install": "签名并安装 LiveContainer+SideStore", "install_livecontainer_step_pairing": "放置配对文件", "sideload_title": "正在安装应用", - "sideload_step_install": "签名并安装应用" + "sideload_step_prepare": "准备安装环境", + "sideload_step_cert": "申请证书", + "sideload_step_profile": "申请描述文件", + "sideload_step_sign": "签名文件", + "sideload_step_transfer": "传输数据", + "sideload_step_install": "安装应用" }, "certificates": { "manage": "管理证书",