diff --git a/ThruRNDIS.xcodeproj/project.pbxproj b/ThruRNDIS.xcodeproj/project.pbxproj index 543c39f..7f00d6a 100644 --- a/ThruRNDIS.xcodeproj/project.pbxproj +++ b/ThruRNDIS.xcodeproj/project.pbxproj @@ -808,7 +808,7 @@ CODE_SIGN_ENTITLEMENTS = ThruRNDIS/ThruRNDIS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ThruRNDIS/Info.plist; @@ -834,7 +834,7 @@ CODE_SIGN_ENTITLEMENTS = ThruRNDIS/Distribution.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = NO; @@ -923,7 +923,7 @@ CODE_SIGN_ENTITLEMENTS = ThruRNDIS/Runtime.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ThruRNDIS/Info.plist; @@ -948,7 +948,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -972,7 +972,7 @@ CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -996,7 +996,7 @@ CODE_SIGN_IDENTITY = "Developer ID Application"; CODE_SIGN_STYLE = Manual; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; ENABLE_DEBUG_DYLIB = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift index c91aa21..1ae977a 100644 --- a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift +++ b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift @@ -8,6 +8,15 @@ import Foundation private enum USBPassthroughPolicy { static let attachFailureSuppressionInterval: TimeInterval = 10 + static let intentionalDisconnectExpectationInterval: TimeInterval = 10 + static let intentionalReenumerationInterval: TimeInterval = 3 +} + +private struct ExpectedAccessoryReenumeration { + let registryID: UInt64 + let deviceDescriptorHash: String + let disconnectDeadline: Date + var reconnectDeadline: Date? } @MainActor @@ -27,9 +36,9 @@ final class USBAccessoryCoordinator { private var pendingAttachAccessoryID: UInt64? private var pendingAttachToken: UUID? private var lastAccessoryEventByDescriptor: [String: (kind: String, date: Date)] = [:] - private var lastAttachAttemptByDescriptor: [String: Date] = [:] private var attachSuppressedUntilByDescriptor: [String: Date] = [:] private var reconnectDescriptorKey: String? + private var expectedAccessoryReenumeration: ExpectedAccessoryReenumeration? private var announcedAccessoryIDs: Set = [] private var isIntentionalVMStopInProgress = false private var isRegistrationPending = false @@ -204,6 +213,7 @@ final class USBAccessoryCoordinator { accessories.removeAll() selectedAccessoryID = nil reconnectDescriptorKey = nil + expectedAccessoryReenumeration = nil announcedAccessoryIDs.removeAll() notifyStateChanged() @@ -269,7 +279,22 @@ final class USBAccessoryCoordinator { } } - func prepareForIntentionalVMStop() { + func prepareForIntentionalVMStop( + suppressReenumerationPrompt: Bool = true + ) { + if suppressReenumerationPrompt, + let attachedAccessoryID, + let record = accessories.first(where: { $0.id == attachedAccessoryID }) { + expectedAccessoryReenumeration = ExpectedAccessoryReenumeration( + registryID: attachedAccessoryID, + deviceDescriptorHash: record.deviceDescriptorHash, + disconnectDeadline: Date().addingTimeInterval( + USBPassthroughPolicy.intentionalDisconnectExpectationInterval + ) + ) + } else if !suppressReenumerationPrompt { + expectedAccessoryReenumeration = nil + } isIntentionalVMStopInProgress = true reportEventLog( "Marked USB passthrough teardown as an intentional VM stop.", @@ -282,7 +307,6 @@ final class USBAccessoryCoordinator { attachedDevice = nil pendingAttachAccessoryID = nil pendingAttachToken = nil - lastAttachAttemptByDescriptor.removeAll() attachSuppressedUntilByDescriptor.removeAll() reconnectDescriptorKey = nil vmSessionAccessoryID = nil @@ -440,7 +464,6 @@ final class USBAccessoryCoordinator { completion: ((Bool) -> Void)? ) { let registryID = accessory.registryID - let descriptorKey = record.descriptorIdentityKey if let vmSessionAccessoryID { onStatusMessage?(String(localized: "Detach the current USB accessory before attaching another USB accessory.")) @@ -480,7 +503,6 @@ final class USBAccessoryCoordinator { let attachToken = UUID() pendingAttachAccessoryID = registryID pendingAttachToken = attachToken - lastAttachAttemptByDescriptor[descriptorKey] = Date() notifyStateChanged() reportEventLog( "USB attach details: \(record.descriptorDiagnosticText), registry " + @@ -610,6 +632,16 @@ final class USBAccessoryCoordinator { level: .debug ) + if consumeExpectedReenumerationIfMatching(record) { + _ = announcedAccessoryIDs.insert(record.id) + reportEventLog( + "USB accessory returned after intentional passthrough release; " + + "automatic attach prompt suppressed for registry " + + "\(record.registryIDText).", + level: .debug + ) + } + let becameReady = previousRecord?.hasConfigurationDescriptor != true && record.hasConfigurationDescriptor let shouldAnnounce = becameReady && attachedAccessoryID != record.id @@ -621,11 +653,24 @@ final class USBAccessoryCoordinator { } private func removeAccessory(_ accessory: AAUSBAccessory) { - let record = USBAccessoryRecord(accessory: accessory) + let record = accessories.first { $0.id == accessory.registryID } + ?? USBAccessoryRecord(accessory: accessory) let wasSelected = selectedAccessoryID == accessory.registryID let wasAttached = attachedAccessoryID == accessory.registryID let wasPendingAttach = pendingAttachAccessoryID == accessory.registryID + if let expectedAccessoryReenumeration, + expectedAccessoryReenumeration.registryID == accessory.registryID { + let now = Date() + if expectedAccessoryReenumeration.disconnectDeadline > now { + self.expectedAccessoryReenumeration?.reconnectDeadline = now.addingTimeInterval( + USBPassthroughPolicy.intentionalReenumerationInterval + ) + } else { + self.expectedAccessoryReenumeration = nil + } + } + accessoryObjects[accessory.registryID] = nil accessories.removeAll { $0.id == accessory.registryID } announcedAccessoryIDs.remove(accessory.registryID) @@ -688,6 +733,35 @@ final class USBAccessoryCoordinator { } } + private func consumeExpectedReenumerationIfMatching( + _ record: USBAccessoryRecord + ) -> Bool { + guard let expectedAccessoryReenumeration else { + return false + } + + let now = Date() + guard let reconnectDeadline = expectedAccessoryReenumeration.reconnectDeadline else { + if expectedAccessoryReenumeration.disconnectDeadline <= now { + self.expectedAccessoryReenumeration = nil + } + return false + } + + guard reconnectDeadline > now else { + self.expectedAccessoryReenumeration = nil + return false + } + + guard expectedAccessoryReenumeration.deviceDescriptorHash + == record.deviceDescriptorHash else { + return false + } + + self.expectedAccessoryReenumeration = nil + return true + } + private func attachSuppressionRemaining(for record: USBAccessoryRecord) -> TimeInterval? { guard let suppressedUntil = attachSuppressedUntilByDescriptor[record.descriptorIdentityKey] else { return nil diff --git a/ThruRNDIS/Stores/TetheringStore.swift b/ThruRNDIS/Stores/TetheringStore.swift index f618fe6..e370e33 100644 --- a/ThruRNDIS/Stores/TetheringStore.swift +++ b/ThruRNDIS/Stores/TetheringStore.swift @@ -619,7 +619,9 @@ final class TetheringStore: ObservableObject { self.workflowCoordinator.prepareForManualVMRestart( attachedAccessoryID: self.attachedAccessoryID ) - self.usbCoordinator.prepareForIntentionalVMStop() + self.usbCoordinator.prepareForIntentionalVMStop( + suppressReenumerationPrompt: false + ) self.vmCoordinator.restart(reason: "manual request") { [weak self] in guard let self else { return } self.vmRestartState = .starting