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