Skip to content
Closed
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
12 changes: 6 additions & 6 deletions ThruRNDIS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
86 changes: 80 additions & 6 deletions ThruRNDIS/Coordinators/USBAccessoryCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import Foundation

private enum USBPassthroughPolicy {
static let attachFailureSuppressionInterval: TimeInterval = 10
static let intentionalDisconnectExpectationInterval: TimeInterval = 10
static let intentionalReenumerationInterval: TimeInterval = 3
Comment thread
Afcoo marked this conversation as resolved.
}

private struct ExpectedAccessoryReenumeration {
let registryID: UInt64
let deviceDescriptorHash: String
let disconnectDeadline: Date
var reconnectDeadline: Date?
}

@MainActor
Expand All @@ -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<UInt64> = []
private var isIntentionalVMStopInProgress = false
private var isRegistrationPending = false
Expand Down Expand Up @@ -204,6 +213,7 @@ final class USBAccessoryCoordinator {
accessories.removeAll()
selectedAccessoryID = nil
reconnectDescriptorKey = nil
expectedAccessoryReenumeration = nil
announcedAccessoryIDs.removeAll()
notifyStateChanged()

Expand Down Expand Up @@ -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
)
)
Comment thread
Afcoo marked this conversation as resolved.
} else if !suppressReenumerationPrompt {
expectedAccessoryReenumeration = nil
}
isIntentionalVMStopInProgress = true
reportEventLog(
"Marked USB passthrough teardown as an intentional VM stop.",
Expand All @@ -282,7 +307,6 @@ final class USBAccessoryCoordinator {
attachedDevice = nil
pendingAttachAccessoryID = nil
pendingAttachToken = nil
lastAttachAttemptByDescriptor.removeAll()
attachSuppressedUntilByDescriptor.removeAll()
reconnectDescriptorKey = nil
vmSessionAccessoryID = nil
Expand Down Expand Up @@ -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."))
Expand Down Expand Up @@ -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 " +
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Comment on lines +756 to +757

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not suppress a different identical USB device

When another unit of the same USB model connects during the three-second re-enumeration window, its device descriptor is typically byte-identical because the descriptor contains only the serial-string index, not the unit's serial value. This comparison therefore consumes the old device's expectation and inserts the new registry ID into announcedAccessoryIDs, permanently suppressing the new device's only attachment prompt even though it was never approved; correlate the reconnect with device identity or topology rather than this model-level hash alone.

AGENTS.md reference: AGENTS.md:L203-L208

Useful? React with 👍 / 👎.

return false
}

self.expectedAccessoryReenumeration = nil
return true
}

private func attachSuppressionRemaining(for record: USBAccessoryRecord) -> TimeInterval? {
guard let suppressedUntil = attachSuppressedUntilByDescriptor[record.descriptorIdentityKey] else {
return nil
Expand Down
4 changes: 3 additions & 1 deletion ThruRNDIS/Stores/TetheringStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down