Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions App/iOSDeveloperToolkit/Model/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ final class AppModel {
}
var journalRecords: [OperationRecord] = []
var operations: [RunningOperation] = []
private(set) var isTerminating = false
var presentedError: PresentedError?
var developerTools: DeveloperToolsStatus?
var readinessResults: [String: [CapabilityResult]] = [:]
Expand Down Expand Up @@ -186,6 +187,14 @@ final class AppModel {
security.cancel()
}

func beginTermination() {
isTerminating = true
}

func cancelTermination() {
isTerminating = false
}

private func apply(_ snapshot: DiscoverySnapshot) {
let previous = Set(self.snapshot.devices.map(\.id))
self.snapshot = snapshot
Expand Down
177 changes: 156 additions & 21 deletions App/iOSDeveloperToolkit/Model/FeatureModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,66 +109,201 @@ final class AppsModel {

// MARK: - Install

/// Immutable input captured before the installation confirmation is presented.
struct AppInstallPlan: Sendable {
let package: PreparedAppPackage
let device: Device
let developerPackage: Bool
}

@Observable
@MainActor
final class InstallModel {
var packageURL: URL?
var inspection: IPAInspection?
var appBundleName: String?
private(set) var packageURL: URL?
private(set) var preparedPackage: PreparedAppPackage?
private var stalePackages: [PreparedAppPackage] = []
private(set) var isInspecting = false
private(set) var inspectionError: String?
private(set) var isInstalling = false
var installAsDeveloperPackage = false
private var selectionID = UUID()
private var inspectionOperation: RunningOperation?
private var installationOperation: RunningOperation?
private var activePreparations = 0
private var isShuttingDown = false
private var cleanupWaiters: [CheckedContinuation<Void, Never>] = []

var hasPreparedResources: Bool { preparedPackage != nil || !stalePackages.isEmpty || activePreparations > 0 || isInstalling }

var isAppBundle: Bool { packageURL?.pathExtension == "app" }
var inspection: IPAInspection? { preparedPackage?.inspection }
var appBundleName: String? { isAppBundle ? preparedPackage?.appName : nil }
var isAppBundle: Bool { packageURL?.pathExtension.lowercased() == "app" }

func choose(_ url: URL, app: AppModel) async {
guard !isShuttingDown && !app.isTerminating else {
app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before choosing another package."))
return
}
guard !isInstalling else {
app.present(ToolkitError(.invalidInput, message: "Wait for the current installation to finish before choosing another package.", recovery: nil, technicalDetail: nil))
return
}
let generation = UUID()
selectionID = generation
inspectionOperation?.cancel()
if let previous = preparedPackage {
do { try previous.remove() }
catch {
stalePackages.append(previous)
preparedPackage = nil
packageURL = nil
inspectionError = "The previous prepared package could not be removed. Review the reported file error before choosing another package."
app.present(error)
return
}
}
packageURL = url
inspection = nil
appBundleName = nil
if url.pathExtension == "app" {
appBundleName = url.deletingPathExtension().lastPathComponent
preparedPackage = nil
inspectionError = nil
isInspecting = true
activePreparations += 1
defer {
if selectionID == generation {
isInspecting = false
inspectionOperation = nil
}
activePreparations -= 1
resumeCleanupIfIdle()
}
let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path], onStart: { self.inspectionOperation = $0 }) { _ in
try await PreparedAppPackage.prepareAsync(url)
}
guard selectionID == generation else {
if let result {
do { try result.remove() }
catch {
stalePackages.append(result)
app.present(error)
}
}
return
}
let result = await app.run("Inspect \(url.lastPathComponent)", workspace: .installApp, target: nil, transport: "Local inspection (Security.framework)", outputPaths: [url.path]) { _ in
try IPAInspector.inspect(url)
preparedPackage = result
if result == nil { inspectionError = "Package inspection did not finish successfully. Choose the package again before installing." }
}

func blockReason(for device: Device) -> String? {
if isShuttingDown { return "Package cleanup is running while the app quits." }
if isInstalling { return "An installation is already running." }
if isInspecting { return "Wait for package inspection to finish." }
guard let preparedPackage else { return inspectionError ?? "Choose and inspect a package before installing." }
do {
try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted)
return nil
} catch { return (error as? ToolkitError)?.message ?? error.localizedDescription }
}

func prepareInstallation(for device: Device, app: AppModel) throws -> AppInstallPlan {
guard !isInstalling && !isInspecting && !isShuttingDown, let preparedPackage else {
throw ToolkitError(.invalidInput, message: "Package inspection must finish successfully before installation.", recovery: "Choose the package again and wait for inspection.", technicalDetail: nil)
}
guard !app.isTerminating, app.selectedTarget == device.target else {
throw ToolkitError(.invalidInput, message: "The installation target changed before confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil)
}
inspection = result
try preparedPackage.validate(for: device.target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted)
return AppInstallPlan(package: preparedPackage, device: device, developerPackage: installAsDeveloperPackage)
}

func install(app: AppModel, device: Device) async {
guard let url = packageURL else { return }
func install(_ plan: AppInstallPlan, app: AppModel) async {
guard !isShuttingDown && !app.isTerminating else {
app.present(ToolkitError.invalidInput("The app is quitting. Cancel quitting before starting an app installation."))
return
}
guard !isInstalling && !isInspecting, preparedPackage?.id == plan.package.id else {
app.present(ToolkitError(.invalidInput, message: "The package changed after this installation was confirmed.", recovery: "Inspect and confirm the selected package again.", technicalDetail: nil))
return
}
guard app.selectedTarget == plan.device.target else {
app.present(ToolkitError(.invalidInput, message: "The installation target changed after confirmation.", recovery: "Select the target again and review a new installation confirmation.", technicalDetail: nil))
return
}
let device = plan.device
let target = device.target
let runner = app.runner
let developer = installAsDeveloperPackage
let succeeded = await app.run("Install \(url.lastPathComponent)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [url.path]) { operation in
let url = plan.package.packageURL
isInstalling = true
defer {
isInstalling = false
installationOperation = nil
resumeCleanupIfIdle()
}
let succeeded = await app.run("Install \(plan.package.appName)", workspace: .installApp, target: target, transport: device.kind == .simulator ? "simctl install" : (device.supportsCoreDevice ? "devicectl device install app" : "AFC + installation_proxy"), argv: [plan.package.originalURL.path], onStart: { self.installationOperation = $0 }) { operation in
let validation = Task.detached(priority: .userInitiated) { try plan.package.revalidate() }
try await withTaskCancellationHandler {
try await validation.value
} onCancel: { validation.cancel() }
try Task.checkCancellation()
try plan.package.validate(for: target, supportsCoreDevice: device.supportsCoreDevice, simulatorBooted: device.simulatorState == .booted)
switch device.kind {
case .simulator:
try await SimulatorClient(runner: runner).install(appAt: url, on: target)
case .physical where device.supportsCoreDevice:
_ = try await CoreDeviceClient(runner: runner).install(appAt: url, on: target)
case .physical:
guard url.pathExtension == "ipa" else { throw ToolkitError(.unsupported, message: "Without Xcode, only .ipa packages can be installed.") }
try await DeviceSession.with(target) { session in
let afc = try await AFCClient.openMedia(session)
let stagedName = "idt-\(UUID().uuidString.prefix(8)).ipa"
try? await afc.makeDirectory("/PublicStaging")
try await afc.ensureDirectory("/PublicStaging")
try await afc.upload(url, to: "/PublicStaging/\(stagedName)") { sent, total in
operation.report("Uploading \(ByteFormatting.string(sent)) of \(ByteFormatting.string(total))", progress: total > 0 ? Double(sent) / Double(total) * 0.5 : nil)
}
await afc.close()
let proxy = try await InstallationProxy.open(session)
defer { Task { await proxy.close() } }
try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: developer) { percent in
try await proxy.install(stagedPackagePath: "PublicStaging/\(stagedName)", developerPackage: plan.developerPackage) { percent in
operation.report("Installing \(percent)%", progress: 0.5 + Double(percent) / 200)
}
}
case .demo:
throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.")
throw ToolkitError(.unsupported, message: "Demo Mode cannot install apps.", recovery: nil, technicalDetail: nil)
}
return true
}
if succeeded == true {
app.statusMessage = "Installed \(inspection?.appName ?? url.lastPathComponent) on \(target.name)."
await app.apps.refresh(app: app, device: device)
app.statusMessage = "Installed \(plan.package.appName) on \(target.name)."
if app.selectedTarget == target { await app.apps.refresh(app: app, device: device) }
}
}

/// Waits for this model's package readers before removing its private copy on normal quit.
func cleanupForTermination() async throws {
isShuttingDown = true
defer { isShuttingDown = false }
selectionID = UUID()
inspectionOperation?.cancel()
installationOperation?.cancel()
if activePreparations > 0 || isInstalling {
await withCheckedContinuation { cleanupWaiters.append($0) }
}
if let preparedPackage {
try preparedPackage.remove()
self.preparedPackage = nil
}
while let stale = stalePackages.first {
try stale.remove()
stalePackages.removeFirst()
}
inspectionOperation = nil
packageURL = nil
inspectionError = nil
isInspecting = false
}

private func resumeCleanupIfIdle() {
guard activePreparations == 0 && !isInstalling else { return }
let waiters = cleanupWaiters
cleanupWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
}

Expand Down
41 changes: 23 additions & 18 deletions App/iOSDeveloperToolkit/Views/AppsViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,21 @@ struct InstallAppView: View {
Task { await install.choose(url, app: model) }
}
}
.disabled(install.isInstalling)
.accessibilityIdentifier("choose-package")
if let url = install.packageURL {
Text(url.lastPathComponent).font(.callout.monospaced()).lineLimit(1).truncationMode(.middle)
}
}
}
if install.isInspecting {
ProgressView("Inspecting the selected package…")
.accessibilityIdentifier("package-inspection-running")
} else if let error = install.inspectionError {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
.accessibilityIdentifier("package-inspection-failed")
}
if let inspection = install.inspection {
InspectionView(inspection: inspection, device: model.selectedDevice)
} else if let name = install.appBundleName {
Expand All @@ -130,37 +139,33 @@ struct InstallAppView: View {
Toggle("Install as developer package", isOn: $install.installAsDeveloperPackage)
.help("Asks iOS to treat the package as a development build. Only meaningful for development-signed apps.")
}
let blocked = blockReason(device)
let blocked = install.blockReason(for: device)
if let blocked {
Label(blocked, systemImage: "hand.raised").foregroundStyle(.orange)
}
Button("Install on \(device.name)…") {
confirmation = PendingConfirmation(title: "Install \(install.inspection?.appName ?? install.packageURL?.lastPathComponent ?? "app")?", detail: "The app will be installed on \(device.name). iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: device.target), target: device.target) {
Task { await install.install(app: model, device: device) }
}
do {
let plan = try install.prepareInstallation(for: device, app: model)
let developerOption = plan.device.kind == .physical && !plan.device.supportsCoreDevice
? " Install as developer package: \(plan.developerPackage ? "on" : "off")."
: ""
confirmation = PendingConfirmation(title: "Install \(plan.package.appName)?", detail: "\(plan.package.originalURL.lastPathComponent) will be installed on \(plan.device.name).\(developerOption) iOS still checks the signature, provisioning, and entitlements.", requirement: .make(for: .deviceChange, target: plan.device.target), target: plan.device.target) {
Task { await install.install(plan, app: model) }
}
} catch { model.present(error) }
}
.disabled(blocked != nil)
.accessibilityIdentifier("install-package")
}
}
}
.onChange(of: model.selectedTarget) { _, _ in confirmation = nil }
.onChange(of: install.preparedPackage?.id) { _, _ in confirmation = nil }
.onChange(of: install.installAsDeveloperPackage) { _, _ in confirmation = nil }
.sheet(item: $confirmation) { pending in
ConfirmationSheet(title: pending.title, detail: pending.detail, requirement: pending.requirement, target: pending.target, commandPreview: nil, onConfirm: pending.action)
}
}

private func blockReason(_ device: Device) -> String? {
let install = model.install
switch device.kind {
case .demo: return "Demo Mode cannot install apps."
case .simulator:
if !install.isAppBundle { return "Simulators install .app bundles built for the simulator, not .ipa files." }
if device.simulatorState != .booted { return "Start the simulator first." }
return nil
case .physical:
if let inspection = install.inspection, !inspection.isInstallable { return inspection.installabilityExplanation }
return nil
}
}
}

struct InspectionView: View {
Expand Down
Loading
Loading