From b4967302a61af5ecb355cd9b8bdd4cbc42ef9c98 Mon Sep 17 00:00:00 2001 From: Afcoo Date: Sat, 29 Aug 2026 22:59:34 +0900 Subject: [PATCH 1/5] fix(usb): suppress prompt after intentional detach --- .../USBAccessoryCoordinator.swift | 64 +++++++++++++++++-- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift index c91aa21..ea2c1db 100644 --- a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift +++ b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift @@ -8,6 +8,13 @@ import Foundation private enum USBPassthroughPolicy { static let attachFailureSuppressionInterval: TimeInterval = 10 + static let intentionalReenumerationInterval: TimeInterval = 3 +} + +private struct ExpectedAccessoryReenumeration { + let registryID: UInt64 + let descriptorIdentityKey: String + var reconnectDeadline: Date? } @MainActor @@ -27,9 +34,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 +211,7 @@ final class USBAccessoryCoordinator { accessories.removeAll() selectedAccessoryID = nil reconnectDescriptorKey = nil + expectedAccessoryReenumeration = nil announcedAccessoryIDs.removeAll() notifyStateChanged() @@ -270,6 +278,13 @@ final class USBAccessoryCoordinator { } func prepareForIntentionalVMStop() { + if let attachedAccessoryID, + let record = accessories.first(where: { $0.id == attachedAccessoryID }) { + expectedAccessoryReenumeration = ExpectedAccessoryReenumeration( + registryID: attachedAccessoryID, + descriptorIdentityKey: record.descriptorIdentityKey + ) + } isIntentionalVMStopInProgress = true reportEventLog( "Marked USB passthrough teardown as an intentional VM stop.", @@ -282,7 +297,6 @@ final class USBAccessoryCoordinator { attachedDevice = nil pendingAttachAccessoryID = nil pendingAttachToken = nil - lastAttachAttemptByDescriptor.removeAll() attachSuppressedUntilByDescriptor.removeAll() reconnectDescriptorKey = nil vmSessionAccessoryID = nil @@ -440,7 +454,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 +493,6 @@ final class USBAccessoryCoordinator { let attachToken = UUID() pendingAttachAccessoryID = registryID pendingAttachToken = attachToken - lastAttachAttemptByDescriptor[descriptorKey] = Date() notifyStateChanged() reportEventLog( "USB attach details: \(record.descriptorDiagnosticText), registry " + @@ -615,17 +627,32 @@ final class USBAccessoryCoordinator { && attachedAccessoryID != record.id && announcedAccessoryIDs.insert(record.id).inserted - if shouldAnnounce { + if shouldAnnounce, + consumeExpectedReenumerationIfMatching(record) { + reportEventLog( + "USB accessory returned after intentional passthrough release; " + + "automatic attach prompt suppressed for registry " + + "\(record.registryIDText).", + level: .debug + ) + } else if shouldAnnounce { onAccessoryAvailable?(record) } } 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 expectedAccessoryReenumeration?.registryID == accessory.registryID { + expectedAccessoryReenumeration?.reconnectDeadline = Date().addingTimeInterval( + USBPassthroughPolicy.intentionalReenumerationInterval + ) + } + accessoryObjects[accessory.registryID] = nil accessories.removeAll { $0.id == accessory.registryID } announcedAccessoryIDs.remove(accessory.registryID) @@ -688,6 +715,31 @@ final class USBAccessoryCoordinator { } } + private func consumeExpectedReenumerationIfMatching( + _ record: USBAccessoryRecord + ) -> Bool { + guard let expectedAccessoryReenumeration else { + return false + } + + guard let reconnectDeadline = expectedAccessoryReenumeration.reconnectDeadline else { + return false + } + + guard reconnectDeadline > Date() else { + self.expectedAccessoryReenumeration = nil + return false + } + + guard expectedAccessoryReenumeration.descriptorIdentityKey + == record.descriptorIdentityKey else { + return false + } + + self.expectedAccessoryReenumeration = nil + return true + } + private func attachSuppressionRemaining(for record: USBAccessoryRecord) -> TimeInterval? { guard let suppressedUntil = attachSuppressedUntilByDescriptor[record.descriptorIdentityKey] else { return nil From 5d8ed3a745628bf06e39f34b3cbbb8b6b513a45e Mon Sep 17 00:00:00 2001 From: Afcoo Date: Sat, 29 Aug 2026 23:07:40 +0900 Subject: [PATCH 2/5] chore(release): bump build number to 16 --- ThruRNDIS.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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; From 16dc69e395fd49fcf83ef1407f4ce1cddc3f48a8 Mon Sep 17 00:00:00 2001 From: Afcoo Date: Sat, 29 Aug 2026 23:08:40 +0900 Subject: [PATCH 3/5] fix(usb): expire stale reenumeration expectations --- .../USBAccessoryCoordinator.swift | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift index ea2c1db..2ac1a73 100644 --- a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift +++ b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift @@ -8,12 +8,14 @@ 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 descriptorIdentityKey: String + let disconnectDeadline: Date var reconnectDeadline: Date? } @@ -282,7 +284,10 @@ final class USBAccessoryCoordinator { let record = accessories.first(where: { $0.id == attachedAccessoryID }) { expectedAccessoryReenumeration = ExpectedAccessoryReenumeration( registryID: attachedAccessoryID, - descriptorIdentityKey: record.descriptorIdentityKey + descriptorIdentityKey: record.descriptorIdentityKey, + disconnectDeadline: Date().addingTimeInterval( + USBPassthroughPolicy.intentionalDisconnectExpectationInterval + ) ) } isIntentionalVMStopInProgress = true @@ -647,10 +652,16 @@ final class USBAccessoryCoordinator { let wasAttached = attachedAccessoryID == accessory.registryID let wasPendingAttach = pendingAttachAccessoryID == accessory.registryID - if expectedAccessoryReenumeration?.registryID == accessory.registryID { - expectedAccessoryReenumeration?.reconnectDeadline = Date().addingTimeInterval( - USBPassthroughPolicy.intentionalReenumerationInterval - ) + 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 @@ -722,11 +733,15 @@ final class USBAccessoryCoordinator { return false } + let now = Date() guard let reconnectDeadline = expectedAccessoryReenumeration.reconnectDeadline else { + if expectedAccessoryReenumeration.disconnectDeadline <= now { + self.expectedAccessoryReenumeration = nil + } return false } - guard reconnectDeadline > Date() else { + guard reconnectDeadline > now else { self.expectedAccessoryReenumeration = nil return false } From 353ba90b40bf71fe949e0a92d7ed227ad3e3992d Mon Sep 17 00:00:00 2001 From: Afcoo Date: Sat, 29 Aug 2026 23:23:44 +0900 Subject: [PATCH 4/5] fix(usb): preserve restart fallback prompt --- ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift | 9 +++++++-- ThruRNDIS/Stores/TetheringStore.swift | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift index 2ac1a73..0bc4477 100644 --- a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift +++ b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift @@ -279,8 +279,11 @@ final class USBAccessoryCoordinator { } } - func prepareForIntentionalVMStop() { - if let attachedAccessoryID, + func prepareForIntentionalVMStop( + suppressReenumerationPrompt: Bool = true + ) { + if suppressReenumerationPrompt, + let attachedAccessoryID, let record = accessories.first(where: { $0.id == attachedAccessoryID }) { expectedAccessoryReenumeration = ExpectedAccessoryReenumeration( registryID: attachedAccessoryID, @@ -289,6 +292,8 @@ final class USBAccessoryCoordinator { USBPassthroughPolicy.intentionalDisconnectExpectationInterval ) ) + } else if !suppressReenumerationPrompt { + expectedAccessoryReenumeration = nil } isIntentionalVMStopInProgress = true reportEventLog( 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 From 49f35aaa1c2c113ad09150862a46b6afbce80382 Mon Sep 17 00:00:00 2001 From: Afcoo Date: Sat, 29 Aug 2026 23:32:25 +0900 Subject: [PATCH 5/5] fix(usb): preserve suppression through descriptor readiness --- .../USBAccessoryCoordinator.swift | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift index 0bc4477..1ae977a 100644 --- a/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift +++ b/ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift @@ -14,7 +14,7 @@ private enum USBPassthroughPolicy { private struct ExpectedAccessoryReenumeration { let registryID: UInt64 - let descriptorIdentityKey: String + let deviceDescriptorHash: String let disconnectDeadline: Date var reconnectDeadline: Date? } @@ -287,7 +287,7 @@ final class USBAccessoryCoordinator { let record = accessories.first(where: { $0.id == attachedAccessoryID }) { expectedAccessoryReenumeration = ExpectedAccessoryReenumeration( registryID: attachedAccessoryID, - descriptorIdentityKey: record.descriptorIdentityKey, + deviceDescriptorHash: record.deviceDescriptorHash, disconnectDeadline: Date().addingTimeInterval( USBPassthroughPolicy.intentionalDisconnectExpectationInterval ) @@ -632,20 +632,22 @@ final class USBAccessoryCoordinator { level: .debug ) - let becameReady = previousRecord?.hasConfigurationDescriptor != true && record.hasConfigurationDescriptor - let shouldAnnounce = becameReady - && attachedAccessoryID != record.id - && announcedAccessoryIDs.insert(record.id).inserted - - if shouldAnnounce, - consumeExpectedReenumerationIfMatching(record) { + 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 ) - } else if shouldAnnounce { + } + + let becameReady = previousRecord?.hasConfigurationDescriptor != true && record.hasConfigurationDescriptor + let shouldAnnounce = becameReady + && attachedAccessoryID != record.id + && announcedAccessoryIDs.insert(record.id).inserted + + if shouldAnnounce { onAccessoryAvailable?(record) } } @@ -751,8 +753,8 @@ final class USBAccessoryCoordinator { return false } - guard expectedAccessoryReenumeration.descriptorIdentityKey - == record.descriptorIdentityKey else { + guard expectedAccessoryReenumeration.deviceDescriptorHash + == record.deviceDescriptorHash else { return false }