From bbfdcd9a554c12f004867c28eb5877fa2529255b Mon Sep 17 00:00:00 2001 From: Yu-Xi Lim Date: Fri, 21 Aug 2026 22:05:30 +0800 Subject: [PATCH] Stop finger taps from arriving as right clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buttonMaskRequired is only evaluated for indirect input devices. A direct touch carries no buttons, so it satisfied both the primary and the secondary tap recognizer; UIKit let only one of them recognize, and the secondary one won. Every tap reached the host as a right click. This was always the case — it only became visible once presses were held long enough for the host to see them at all. Restricting the secondary recognizer to indirect pointer input leaves finger taps to the primary one, while a trackpad or mouse right click still routes correctly. Also bumps the marketing version for the next release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XU7Ck7AbZ9KpRhdPbRzNj8 --- KVMConsoleiPad/Input/PointerCaptureView.swift | 5 ++++ KVMConsoleiPadTests/NanoKVMiPadTests.swift | 24 +++++++++++++++++++ project.yml | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/KVMConsoleiPad/Input/PointerCaptureView.swift b/KVMConsoleiPad/Input/PointerCaptureView.swift index 998ca6f..dbb2e26 100644 --- a/KVMConsoleiPad/Input/PointerCaptureView.swift +++ b/KVMConsoleiPad/Input/PointerCaptureView.swift @@ -79,6 +79,11 @@ final class PointerCaptureUIView: UIView, UIGestureRecognizerDelegate { let secondaryTap = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryTap(_:))) secondaryTap.buttonMaskRequired = .secondary + // `buttonMaskRequired` is only evaluated for indirect input devices, so on its own it does + // not stop a finger from matching this recognizer — a direct touch carries no buttons and + // satisfies both tap recognizers. UIKit then lets only one of them recognize, and this one + // wins, turning every tap into a right click. Restrict it to the input it is actually for. + secondaryTap.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)] addGestureRecognizer(secondaryTap) } diff --git a/KVMConsoleiPadTests/NanoKVMiPadTests.swift b/KVMConsoleiPadTests/NanoKVMiPadTests.swift index 22bc6b6..c5baecb 100644 --- a/KVMConsoleiPadTests/NanoKVMiPadTests.swift +++ b/KVMConsoleiPadTests/NanoKVMiPadTests.swift @@ -77,6 +77,30 @@ final class KVMConsoleiPadTests: XCTestCase { ) } + // MARK: Tap button routing + + /// A direct touch carries no buttons, and `buttonMaskRequired` is only evaluated for indirect + /// input — so without a touch-type restriction a finger tap matches the secondary recognizer + /// too, and UIKit lets that one win. Every tap then arrives at the host as a right click. + @MainActor + func test_secondaryTapIgnoresDirectTouches() { + let view = PointerCaptureUIView() + let taps = (view.gestureRecognizers ?? []).compactMap { $0 as? UITapGestureRecognizer } + let secondary = taps.first { $0.buttonMaskRequired == .secondary } + let primary = taps.first { $0.buttonMaskRequired == .primary } + + XCTAssertEqual( + secondary?.allowedTouchTypes, + [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)], + "a finger tap must never reach the secondary recognizer" + ) + XCTAssertNotNil(primary, "finger taps still need a primary recognizer to land on") + XCTAssertFalse( + primary?.allowedTouchTypes == [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)], + "the primary recognizer must keep accepting direct touches" + ) + } + // MARK: Synthesized tap dwell @MainActor diff --git a/project.yml b/project.yml index 53afa40..034150d 100644 --- a/project.yml +++ b/project.yml @@ -20,7 +20,7 @@ settings: # Single source of truth for app version, shared by both targets via # $(MARKETING_VERSION) / $(CURRENT_PROJECT_VERSION) references in their # Info.plist. CI overrides CURRENT_PROJECT_VERSION per release (timestamp). - MARKETING_VERSION: "1.0.4" + MARKETING_VERSION: "1.0.5" CURRENT_PROJECT_VERSION: "1" targets: KVMConsole: