diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme index 5a9505ea..eaa731ba 100644 --- a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy (Managed - Beta).xcscheme @@ -77,6 +77,10 @@ + + + + diff --git a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy.xcscheme b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy.xcscheme index 927c2733..12bffc2e 100644 --- a/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy.xcscheme +++ b/VirtualBuddy.xcodeproj/xcshareddata/xcschemes/VirtualBuddy.xcscheme @@ -59,6 +59,10 @@ argument = "-VBSimulateNonAPFSVolume YES" isEnabled = "NO"> + + { get } } + +public struct RestoreFailure: LocalizedError, Sendable { + public let message: String + public let diagnosticFileURLs: [URL] + + public var errorDescription: String? { message } + + public init(message: String, diagnosticFileURLs: [URL]) { + self.message = message + self.diagnosticFileURLs = diagnosticFileURLs + } +} diff --git a/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift b/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift index eb7c0b6f..8f44f632 100644 --- a/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift +++ b/VirtualCore/Source/Restore/Installation/VirtualInstallationRestoreBackend.swift @@ -7,6 +7,15 @@ import VirtualInstallation public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRestoreBackend { private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VirtualInstallationRestoreBackend.self)) + #if DEBUG + /// Enables an end-to-end VirtualInstallation failure using `-VBSimulateInstallFailure YES`. + public static var isFailureSimulationEnabled: Bool { + UserDefaults.standard.bool(forKey: "VBSimulateInstallFailure") + } + + private static let failureSimulationECID: ECID = 0xF411_0000_0000_0001 + #endif + public var consolePredicate: LogStreamer.Predicate { .custom(kVirtualInstallationUnifiedLogPredicate) } public let model: VBVirtualMachine @@ -28,6 +37,14 @@ public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRes public var virtualMachine: AnyPublisher { virtualMachineSubject.eraseToAnyPublisher() } public func install() async throws { + #if DEBUG + if Self.isFailureSimulationEnabled { + logger.notice("Running debug-only end-to-end install failure simulation") + try await runInstaller(ecid: Self.failureSimulationECID, simulateFailure: true) + return + } + #endif + logger.debug("Install - creating configuration") let installModel = model.forInstallation() @@ -52,7 +69,15 @@ public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRes logger.debug("Activating installer") - let installer = VIVirtualMachineInstaller(ecid: ecid, bundleURL: restoreImageFileURL) + try await runInstaller(ecid: ecid, simulateFailure: false) + } + + private func runInstaller(ecid: ECID, simulateFailure: Bool) async throws { + let installer = VIVirtualMachineInstaller( + ecid: ecid, + bundleURL: restoreImageFileURL, + simulateFailure: simulateFailure + ) _installer = installer @@ -64,7 +89,14 @@ public final class VirtualInstallationRestoreBackend: VirtualMachineProvidingRes try Task.checkCancellation() - try await installer.install() + do { + try await installer.install() + } catch let error as DeviceRestoreFailure { + throw RestoreFailure( + message: error.localizedDescription, + diagnosticFileURLs: error.logFileURLs + ) + } } public func cancel() async { diff --git a/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift b/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift index 14b7d0ec..9e5e5d4c 100644 --- a/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift +++ b/VirtualInstallation/Source/Backend/DeviceRestoreBackend.swift @@ -5,6 +5,21 @@ struct DeviceRestoreLoggers: @unchecked Sendable { var device: RestoreLog? = nil var host: RestoreLog? = nil var serial: RestoreLog? = nil + + var fileURLs: [URL] { + [global, device, host, serial].compactMap { log in + guard let fileURL = log?.fileURL, + FileManager.default.fileExists(atPath: fileURL.path) else { return nil } + return fileURL + } + } + + var mostRecentRestoreError: NSError? { + [global, host, device, serial] + .lazy + .compactMap { $0?.mostRecentRestoreError() } + .first + } } typealias DeviceRestoreProgressClosure = @Sendable (_ info: CFDictionary) -> Void diff --git a/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift b/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift index 7745df91..f7bc9261 100644 --- a/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift +++ b/VirtualInstallation/Source/Backend/DeviceRestoreDriver.swift @@ -10,6 +10,7 @@ final class DeviceRestoreDriver: @unchecked Sendable { private let backend: any DeviceRestoreBackend private let personalizedBundleURL: URL + private let logDirectoryURL: URL let artifactStorageURL: URL let loggers: DeviceRestoreLoggers @@ -26,10 +27,16 @@ final class DeviceRestoreDriver: @unchecked Sendable { .appending(path: "Personalized_\(bundleURL.deletingPathExtension().lastPathComponent)_\(ecid)_\(Int(Date.now.timeIntervalSinceReferenceDate))", directoryHint: .isDirectory) .ensureExistingDirectory(createIfNeeded: true) - let logBaseURL = try artifactStorageURL + let logRootURL = try artifactStorageURL .appending(path: "Logs", directoryHint: .isDirectory) .ensureExistingDirectory(createIfNeeded: true) + let logBaseURL = try logRootURL + .appending(path: "Restore_\(ecid)_\(UUID().uuidString)", directoryHint: .isDirectory) + .ensureExistingDirectory(createIfNeeded: true) + + self.logDirectoryURL = logBaseURL + let loggers = DeviceRestoreLoggers( global: RestoreLog(fileURL: logBaseURL.appending(path: "global.log")), device: RestoreLog(fileURL: logBaseURL.appending(path: "device.log")), @@ -52,14 +59,34 @@ final class DeviceRestoreDriver: @unchecked Sendable { try backend.restore(deviceECID: ecid, options: options, loggers: loggers) { [weak self] info in do { - let state = try DeviceRestoreState(info: info) + var state = try DeviceRestoreState(info: info) + + if case .failure(let reportedError) = state.outcome { + let error = reportedError ?? self?.loggers.mostRecentRestoreError.map(CodableError.init) + state = state.replacingFailure(error: error, logFileURLs: self?.loggers.fileURLs ?? []) + } + progressHandler(state) + + if case .success = state.outcome { + self?.discardLogs() + } } catch { self?.logger.error("Failed to parse progress info: \(error, privacy: .public). Info:\n\(info, privacy: .public)") } } } + private func discardLogs() { + guard FileManager.default.fileExists(atPath: logDirectoryURL.path) else { return } + + do { + try FileManager.default.removeItem(at: logDirectoryURL) + } catch { + logger.error("Failed to discard logs for a successful restore: \(error, privacy: .public)") + } + } + private static let preservePersonalizedBundles = ProcessInfo.processInfo.environment["VI_PRESERVE_PERSONALIZED_BUNDLES"] == "1" private func buildRestoreOptions() -> RestoreOptionsDictionary { diff --git a/VirtualInstallation/Source/Backend/RestoreLog.swift b/VirtualInstallation/Source/Backend/RestoreLog.swift index 1d052ddb..4e35aa52 100644 --- a/VirtualInstallation/Source/Backend/RestoreLog.swift +++ b/VirtualInstallation/Source/Backend/RestoreLog.swift @@ -2,6 +2,8 @@ import Foundation import os public final class RestoreLog: @unchecked Sendable { + private static let maximumErrorSearchLength: UInt64 = 4 * 1_024 * 1_024 + private let logger: Logger public let fileURL: URL @@ -46,6 +48,55 @@ public final class RestoreLog: @unchecked Sendable { } } + func mostRecentRestoreError() -> NSError? { + do { + let contents = try tailContents(maximumLength: Self.maximumErrorSearchLength) + return Self.mostRecentRestoreError(in: contents) + } catch { + logger.error("Failed to read restore log while looking for an error: \(error, privacy: .public)") + return nil + } + } + + static func mostRecentRestoreError(in contents: String) -> NSError? { + let domainPrefix = "CFError domain:" + let codePrefix = " code:" + let descriptionPrefix = " description:" + + for line in contents.components(separatedBy: .newlines).reversed() { + guard let domainPrefixRange = line.range(of: domainPrefix) else { continue } + + let domainStart = domainPrefixRange.upperBound + guard let codePrefixRange = line.range( + of: codePrefix, + range: domainStart..(initialState: nil) private var fileHandle: FileHandle { get throws { @@ -79,5 +130,17 @@ public final class RestoreLog: @unchecked Sendable { } } + private func tailContents(maximumLength: UInt64) throws -> String { + let handle = try FileHandle(forReadingFrom: fileURL) + defer { try? handle.close() } + + let endOffset = try handle.seekToEnd() + let startOffset = endOffset > maximumLength ? endOffset - maximumLength : 0 + try handle.seek(toOffset: startOffset) + + let data = try handle.readToEnd() ?? Data() + return String(decoding: data, as: UTF8.self) + } + deinit { invalidate() } } diff --git a/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift b/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift index afd7f6ba..a3dcc5fa 100644 --- a/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift +++ b/VirtualInstallation/Source/Backend/TestDeviceRestoreBackend.swift @@ -1,17 +1,29 @@ import Foundation +#if DEBUG final class TestDeviceRestoreBackend: DeviceRestoreBackend, @unchecked Sendable { let stateDictionaries: [CFDictionary] let minTransitionIntervalMS: Int let maxTransitionIntervalMS: Int - - init(stateDictionaries: [CFDictionary] = DeviceRestoreState.testDictionaries, minTransitionIntervalMS: Int = 5, maxTransitionIntervalMS: Int = 30) { - self.stateDictionaries = stateDictionaries + let simulatingFailure: Bool + + init( + stateDictionaries: [CFDictionary]? = nil, + simulatingFailure: Bool = false, + minTransitionIntervalMS: Int = 5, + maxTransitionIntervalMS: Int = 30 + ) { + self.stateDictionaries = stateDictionaries ?? (simulatingFailure ? DeviceRestoreState.testDictionariesError : DeviceRestoreState.testDictionaries) + self.simulatingFailure = simulatingFailure self.minTransitionIntervalMS = minTransitionIntervalMS self.maxTransitionIntervalMS = maxTransitionIntervalMS } func restore(deviceECID: ECID, options: [String : AnyHashable], loggers: DeviceRestoreLoggers, progress: @escaping DeviceRestoreProgressClosure) throws { + if simulatingFailure { + try writeSimulatedFailureLogs(to: loggers) + } + Task { for dictionary in stateDictionaries { progress(dictionary) @@ -22,6 +34,20 @@ final class TestDeviceRestoreBackend: DeviceRestoreBackend, @unchecked Sendable } } } + + private func writeSimulatedFailureLogs(to loggers: DeviceRestoreLoggers) throws { + let logs: [(RestoreLog?, String)] = [ + (loggers.global, .simulatedGlobalRestoreFailureLog), + (loggers.device, .simulatedDeviceRestoreFailureLog), + (loggers.host, .simulatedHostRestoreFailureLog), + (loggers.serial, .simulatedSerialRestoreFailureLog), + ] + + for (log, contents) in logs { + guard let log else { continue } + try contents.write(to: log.fileURL, atomically: true, encoding: .utf8) + } + } } private extension String { @@ -31,6 +57,28 @@ private extension String { } return try! String(contentsOf: url, encoding: .utf8) }() + + static let simulatedGlobalRestoreFailureLog = """ + [12:00:00.000] (Info) - Starting simulated MobileDevice restore + [12:00:01.000] (Default) - dumping CFError returned by restored: + [12:00:01.000] (Default) - CFError domain:AMRestoreErrorDomain code:10 description:Simulated MobileDevice restore failure. This error was generated by VBSimulateInstallFailure. + [12:00:01.000] (Default) - Restore failed (result = 10) + """ + + static let simulatedDeviceRestoreFailureLog = """ + restore-agent: Beginning simulated restore + restore-agent: Reporting simulated terminal failure + """ + + static let simulatedHostRestoreFailureLog = """ + Host initiated a debug-only simulated MobileDevice restore. + Host received AMRestoreErrorDomain error 10. + """ + + static let simulatedSerialRestoreFailureLog = """ + [serial] simulated restore boot + [serial] simulated restore failure + """ } extension DeviceRestoreState { @@ -46,25 +94,29 @@ extension DeviceRestoreState { }() static let testStatesDistinctOperationsOnly: [DeviceRestoreState] = { - var statesByOperation: [RestoreOperation: DeviceRestoreState] = [:] + return try! testDictionariesDistinctOperationsOnly.map { + try DeviceRestoreState(info: $0) + } + }() - for state in testStates { - let operation = state.operation - if statesByOperation[operation] == nil { statesByOperation[operation] = state } + static let testStatesError: [DeviceRestoreState] = { + return try! testDictionariesError.map { + try DeviceRestoreState(info: $0) } + }() - return Array(statesByOperation.values).sorted { stateA, stateB in - guard let idxA = testStates.firstIndex(of: stateA), - let idxB = testStates.firstIndex(of: stateB) else { - return false - } + nonisolated(unsafe) static let testDictionariesDistinctOperationsOnly: [CFDictionary] = { + var operations = Set() - return idxA < idxB + return testDictionaries.filter { dictionary in + guard let state = try? DeviceRestoreState(info: dictionary), + state.outcome == nil else { return false } + return operations.insert(state.operation).inserted } }() - static let testStatesError: [DeviceRestoreState] = { - testStatesDistinctOperationsOnly.prefix(testStatesDistinctOperationsOnly.count - 2) + [.testError] + nonisolated(unsafe) static let testDictionariesError: [CFDictionary] = { + testDictionariesDistinctOperationsOnly + [DeviceRestoreState.testErrorDictionary] }() } @@ -80,25 +132,14 @@ private extension DeviceRestoreState { outcome: nil ) - static let testError = DeviceRestoreState( - progress: 1, - overallProgress: 1, - operation: 0, - operationName: AMRLocalizedCopyStringForAMROperation(0) as String, - status: "Failed", - outcome: .failure(CodableError(NSError.testRestoreError)) - ) -} - -private extension NSError { - static var testRestoreError: NSError { - NSError(domain: "AMRestoreErrorDomain", code: 3194, userInfo: [ - NSLocalizedDescriptionKey : "Personalization failed", - NSUnderlyingErrorKey: NSError(domain: "AMRestoreErrorDomain", code: 3194, userInfo: [ - NSLocalizedDescriptionKey: "Declined to authorize this image on this device for this user." - ]) - ]) - } + /// Intentionally omits the `Error` field so the terminal state exercises error extraction from `global.log`. + nonisolated(unsafe) static let testErrorDictionary: CFDictionary = [ + "DeviceState": 1, + "Operation": RestoreOperation(0), + "OverallProgress": 100, + "Progress": 100, + "Status": "Failed", + ] as CFDictionary } /** @@ -154,3 +195,4 @@ private func readRestoreStatesFromLog(_ log: String) -> [CFDictionary] { return output } +#endif diff --git a/VirtualInstallation/Source/VIVirtualMachineInstaller.swift b/VirtualInstallation/Source/VIVirtualMachineInstaller.swift index ef7e8a38..c37879dc 100644 --- a/VirtualInstallation/Source/VIVirtualMachineInstaller.swift +++ b/VirtualInstallation/Source/VIVirtualMachineInstaller.swift @@ -5,13 +5,21 @@ import os public final class VIVirtualMachineInstaller: @unchecked Sendable { public let ecid: ECID public let bundleURL: URL + private let simulateFailure: Bool private let client: VirtualInstallationClient public let progress: Progress private let logger = Logger(subsystem: kVirtualInstallationSubsystem, category: String(describing: VIVirtualMachineInstaller.self)) - public init(ecid: ECID, bundleURL: URL) { + /// - Parameter simulateFailure: In debug builds, requests an end-to-end simulated failure from the XPC service. + /// Ignored in release builds. + public init(ecid: ECID, bundleURL: URL, simulateFailure: Bool = false) { self.ecid = ecid self.bundleURL = bundleURL + #if DEBUG + self.simulateFailure = simulateFailure + #else + self.simulateFailure = false + #endif self.client = VirtualInstallationClient() self.progress = Progress() progress.totalUnitCount = 100 @@ -36,14 +44,21 @@ public final class VIVirtualMachineInstaller: @unchecked Sendable { case .success: continuation.resume() case .failure(let error): - continuation.resume(throwing: error ?? CocoaError(.coderValueNotFound)) + continuation.resume(throwing: DeviceRestoreFailure( + underlyingError: error, + logFileURLs: state.logFileURLs + )) } } } } } - client.startVirtualMachineInstallation(ecid: ecid, restoreBundleURL: bundleURL) { error in + client.startVirtualMachineInstallation( + ecid: ecid, + restoreBundleURL: bundleURL, + simulateFailure: simulateFailure + ) { error in if let error { continuation.resume(throwing: error) } diff --git a/VirtualInstallation/Source/XPC/Protocols.swift b/VirtualInstallation/Source/XPC/Protocols.swift index 202d1af6..8694c0d3 100644 --- a/VirtualInstallation/Source/XPC/Protocols.swift +++ b/VirtualInstallation/Source/XPC/Protocols.swift @@ -2,7 +2,12 @@ import Foundation @objc(VirtualInstallationServiceProtocol) public protocol VirtualInstallationServiceProtocol { - func startVirtualMachineInstallation(ecid: ECID, restoreBundleURL: URL, reply: @escaping @Sendable (_ error: Error?) -> ()) + func startVirtualMachineInstallation( + ecid: ECID, + restoreBundleURL: URL, + simulateFailure: Bool, + reply: @escaping @Sendable (_ error: Error?) -> () + ) func cancelVirtualMachineInstallation(ecid: ECID, reply: @escaping @Sendable (_ error: Error?) -> ()) } diff --git a/VirtualInstallation/Source/XPC/Types.swift b/VirtualInstallation/Source/XPC/Types.swift index d8690dae..1c52e18e 100644 --- a/VirtualInstallation/Source/XPC/Types.swift +++ b/VirtualInstallation/Source/XPC/Types.swift @@ -8,14 +8,16 @@ public typealias RestoreOptionsDictionary = [String : AnyHashable] public typealias RestoreOperation = Int32 /// A type that can be used to wrap any `Error` as a `Codable` and `Hashable` container that can be stored as part of another type. -public nonisolated struct CodableError: LocalizedError, CustomNSError, Codable, Hashable, Sendable { +public nonisolated struct CodableError: LocalizedError, Codable, Hashable, Sendable { public private(set) var domain: String public private(set) var code: Int - public private(set) var errorDescription: String + public private(set) var message: String public private(set) var failureReason: String? public private(set) var helpAnchor: String? public private(set) var recoverySuggestion: String? public private(set) var info: [String: String] + + public var errorDescription: String? { message } } public nonisolated extension CodableError { @@ -23,7 +25,7 @@ public nonisolated extension CodableError { let nsError = error as NSError self.domain = nsError.domain self.code = nsError.code - self.errorDescription = nsError.localizedDescription + self.message = nsError.localizedDescription self.failureReason = nsError.localizedFailureReason self.helpAnchor = nsError.helpAnchor self.recoverySuggestion = nsError.localizedRecoverySuggestion @@ -37,7 +39,7 @@ public nonisolated extension CodableError { init(message: String) { self.domain = kVirtualInstallationSubsystem self.code = 0 - self.errorDescription = message + self.message = message self.info = [NSLocalizedFailureReasonErrorKey : message] } } @@ -63,6 +65,21 @@ public struct DeviceRestoreState: Hashable, Codable, Sendable { public let operationName: String? public let status: String? public private(set) var outcome: DeviceRestoreOutcome? + public private(set) var logFileURLs: [URL] = [] +} + +public struct DeviceRestoreFailure: LocalizedError, Hashable, Codable, Sendable { + public let underlyingError: CodableError? + public let logFileURLs: [URL] + + public var errorDescription: String? { + underlyingError?.errorDescription ?? "The virtual machine restore failed." + } + + init(underlyingError: CodableError?, logFileURLs: [URL]) { + self.underlyingError = underlyingError + self.logFileURLs = logFileURLs + } } // MARK: - AMD Serialization @@ -110,9 +127,10 @@ extension DeviceRestoreState { } } - func replacingOutcome(with error: NSError) -> Self { + func replacingFailure(error: CodableError?, logFileURLs: [URL]) -> Self { var mSelf = self - mSelf.outcome = .failure(CodableError(error)) + mSelf.outcome = .failure(error) + mSelf.logFileURLs = logFileURLs return mSelf } } diff --git a/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift b/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift index 8e86aa62..b05ba09a 100644 --- a/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift +++ b/VirtualInstallation/Source/XPC/VirtualInstallationClient.swift @@ -105,14 +105,23 @@ final class VirtualInstallationClient: NSObject, VirtualInstallationClientProtoc // MARK: - Client -> Server - func startVirtualMachineInstallation(ecid: ECID, restoreBundleURL: URL, completion: @escaping @Sendable (_ error: Error?) -> ()) { - logger.debug("Start for ECID \(ecid), bundle \(restoreBundleURL.safePath)") + func startVirtualMachineInstallation( + ecid: ECID, + restoreBundleURL: URL, + simulateFailure: Bool, + completion: @escaping @Sendable (_ error: Error?) -> () + ) { + logger.debug("Start for ECID \(ecid), bundle \(restoreBundleURL.safePath), simulate failure: \(simulateFailure)") withService { [weak self] result in do { let service = try result.get() - service.startVirtualMachineInstallation(ecid: ecid, restoreBundleURL: restoreBundleURL) { [weak self] error in + service.startVirtualMachineInstallation( + ecid: ecid, + restoreBundleURL: restoreBundleURL, + simulateFailure: simulateFailure + ) { [weak self] error in if let error { self?.logger.error("Received startVirtualMachineInstallation reply with error: \(error, privacy: .public)") } else { diff --git a/VirtualInstallation/Source/XPC/VirtualInstallationService.swift b/VirtualInstallation/Source/XPC/VirtualInstallationService.swift index 0beb6df3..9902d3eb 100644 --- a/VirtualInstallation/Source/XPC/VirtualInstallationService.swift +++ b/VirtualInstallation/Source/XPC/VirtualInstallationService.swift @@ -13,14 +13,6 @@ import os super.init() } - private let backend: any DeviceRestoreBackend = { - if ProcessInfo.virtualInstallationTestModeEnabled { - TestDeviceRestoreBackend() - } else { - AppleMobileDeviceRestoreBackend() - } - }() - private let _driver = OSAllocatedUnfairLock(initialState: nil) private var driver: DeviceRestoreDriver? { get { _driver.withLock { $0 } } @@ -57,8 +49,13 @@ import os // MARK: - Client -> Service - public func startVirtualMachineInstallation(ecid: ECID, restoreBundleURL: URL, reply: @escaping @Sendable ((any Error)?) -> ()) { - logger.notice("Installation requested for ECID \(ecid), bundle \(restoreBundleURL.safePath)") + public func startVirtualMachineInstallation( + ecid: ECID, + restoreBundleURL: URL, + simulateFailure: Bool, + reply: @escaping @Sendable ((any Error)?) -> () + ) { + logger.notice("Installation requested for ECID \(ecid), bundle \(restoreBundleURL.safePath), simulate failure: \(simulateFailure)") do { guard !cancelled else { @@ -69,6 +66,7 @@ import os } do { + let backend = makeRestoreBackend(simulateFailure: simulateFailure) let newDriver = try DeviceRestoreDriver(ecid: ecid, bundleURL: restoreBundleURL, backend: backend) self.driver = newDriver @@ -92,6 +90,20 @@ import os } } + private func makeRestoreBackend(simulateFailure: Bool) -> any DeviceRestoreBackend { + #if DEBUG + if simulateFailure { + return TestDeviceRestoreBackend(simulatingFailure: true) + } + + if ProcessInfo.virtualInstallationTestModeEnabled { + return TestDeviceRestoreBackend() + } + #endif + + return AppleMobileDeviceRestoreBackend() + } + public func cancelVirtualMachineInstallation(ecid: ECID, reply: @escaping @Sendable ((any Error)?) -> ()) { logger.notice("Cancellation requested for ECID \(ecid)") diff --git a/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift b/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift index 7999b985..50017c62 100644 --- a/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift +++ b/VirtualUI/Source/Installer/Steps/InstallProgressStepView.swift @@ -36,7 +36,13 @@ struct InstallProgressStepView: View { } var body: some View { - if let status { + if case .error(let message) = viewModel.state { + InstallationFailureView( + message: message, + hasMobileDeviceLogs: !viewModel.installationLogFiles.isEmpty, + exportLogs: viewModel.exportInstallationLogs + ) + } else if let status { VirtualBuddyMonoProgressView(progress: progress, status: status, style: style) .textSelection(.enabled) } else if let virtualMachine = viewModel.virtualMachine { @@ -49,6 +55,38 @@ struct InstallProgressStepView: View { } } +private struct InstallationFailureView: View { + let message: String + let hasMobileDeviceLogs: Bool + let exportLogs: () -> Void + + var body: some View { + VStack(spacing: 20) { + VirtualBuddyMonoProgressView( + progress: nil, + status: Text(message), + style: .failure + ) + .textSelection(.enabled) + + if hasMobileDeviceLogs { + Button(action: exportLogs) { + Label { + Text( + "Export Logs…", + bundle: #bundle, + comment: "Button shown after a virtual machine restore fails." + ) + } icon: { + Image(systemName: "square.and.arrow.up") + } + } + .padding() + } + } + } +} + #if DEBUG #Preview { VMInstallationWizard.preview(step: .install) diff --git a/VirtualUI/Source/Installer/VMInstallationViewModel.swift b/VirtualUI/Source/Installer/VMInstallationViewModel.swift index d96545fe..db7aac32 100644 --- a/VirtualUI/Source/Installer/VMInstallationViewModel.swift +++ b/VirtualUI/Source/Installer/VMInstallationViewModel.swift @@ -540,7 +540,10 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { private func createRestoreBackend(for model: VBVirtualMachine, restoreURL: URL, forceVirtualInstallation: Bool) -> RestoreBackend { let Backend: RestoreBackend.Type #if DEBUG - if UserDefaults.standard.bool(forKey: "VBSimulateInstall") || ProcessInfo.isSwiftUIPreview { + if VirtualInstallationRestoreBackend.isFailureSimulationEnabled { + UILog("Using VirtualInstallation restore backend to simulate an install failure") + Backend = VirtualInstallationRestoreBackend.self + } else if UserDefaults.standard.bool(forKey: "VBSimulateInstall") || ProcessInfo.isSwiftUIPreview { Backend = SimulatedRestoreBackend.self } else if restoreURL == SimulatedDownloadBackend.localFileURL { UILog("⚠️ Using simulated installer because the download was also simulated.") @@ -564,6 +567,7 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { @Published private(set) var virtualMachine: VZVirtualMachine? = nil @Published private(set) var consolePredicate: LogStreamer.Predicate? = ProcessInfo.isSwiftUIPreview ? .process("Xcode") : nil @Published private(set) var installationStartTime = Date.now + @Published private(set) var installationLogFiles = [URL]() private var installationTask: Task? @@ -572,6 +576,8 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { } private func _runMacInstallation() async { + installationLogFiles = [] + guard let restoreURL = data.localRestoreImageURL else { state = .error("Missing local restore image URL") return @@ -652,6 +658,11 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { UILog("Installation task finished successfully") } catch is CancellationError { + } catch let error as RestoreFailure { + UILog("Installation task finished with restore error \(error)") + + installationLogFiles = error.diagnosticFileURLs + state = .error(error.localizedDescription) } catch let error as VZError { handleVirtualMachineValidationError(error) } catch { @@ -720,6 +731,57 @@ final class VMInstallationViewModel: ObservableObject, @unchecked Sendable { installationTask?.cancel() } + func exportInstallationLogs() { + let existingLogFiles = installationLogFiles.filter { + FileManager.default.fileExists(atPath: $0.path) + } + + guard !existingLogFiles.isEmpty else { + NSAlert(error: CocoaError( + .fileNoSuchFile, + userInfo: [NSLocalizedDescriptionKey: "The MobileDevice log files are no longer available."] + )).runModal() + return + } + + guard let parentDirectoryURL = NSOpenPanel.run( + accepting: [.folder], + defaultDirectoryKey: "mobileDeviceLogs", + prompt: String(localized: "Export", bundle: #bundle, comment: "Button in a folder picker that exports MobileDevice restore logs.") + ) else { return } + + do { + let exportDirectoryURL = uniqueLogExportDirectory(in: parentDirectoryURL) + try FileManager.default.createDirectory(at: exportDirectoryURL, withIntermediateDirectories: false) + + for sourceURL in existingLogFiles { + let destinationURL = exportDirectoryURL.appending(component: sourceURL.lastPathComponent) + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } + + NSWorkspace.shared.activateFileViewerSelecting([exportDirectoryURL]) + } catch { + NSAlert(error: error).runModal() + } + } + + private func uniqueLogExportDirectory(in parentDirectoryURL: URL) -> URL { + let baseName = String( + localized: "VirtualBuddy MobileDevice Logs", + bundle: #bundle, + comment: "Name of a folder containing exported MobileDevice restore logs." + ) + var candidateURL = parentDirectoryURL.appending(path: baseName, directoryHint: .isDirectory) + var suffix = 2 + + while FileManager.default.fileExists(atPath: candidateURL.path) { + candidateURL = parentDirectoryURL.appending(path: "\(baseName) \(suffix)", directoryHint: .isDirectory) + suffix += 1 + } + + return candidateURL + } + private var needsConfirmationBeforeClosing: Bool { /// Require confirmation as soon as a VM bundle has been created for this install and it's not finished. step != .done && machine != nil