From f0ff484f17363f27b5a317faa32666592e8df5e4 Mon Sep 17 00:00:00 2001 From: Yu-Xi Lim Date: Fri, 21 Aug 2026 22:33:43 +0800 Subject: [PATCH] Add a touchscreen right click and a seam for the release-bypass tests Restricting the secondary tap recognizer to indirect pointer input left no way to right-click without a trackpad or mouse attached. A two-finger tap fills that gap, routed through the same click path so it gets the same hold. It is restricted to direct touches because a trackpad's two-finger tap already arrives as an indirect secondary click, and both firing would double up. A stationary two-finger tap starts neither the pan nor the pinch, so wheel scrolling and zoom are unaffected. Long-press was the alternative but collides with the one-finger drag, which is already a held left button: pausing before a drag would fire a spurious right click first. ViewerViewModel's session is now injectable, so sendInputRelease's deliberate bypass of the capture guard is covered by tests rather than by reading it. The parameter is defaulted, so every existing call site is unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XU7Ck7AbZ9KpRhdPbRzNj8 --- KVMConsoleiPad/Input/PointerCaptureView.swift | 9 ++ KVMConsoleiPadTests/NanoKVMiPadTests.swift | 15 +++ .../Sources/KVMCore/UI/ViewerViewModel.swift | 3 +- .../ViewerViewModelInputReleaseTests.swift | 94 +++++++++++++++++++ project.yml | 2 +- 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 KVMCore/Tests/KVMCoreTests/ViewerViewModelInputReleaseTests.swift diff --git a/KVMConsoleiPad/Input/PointerCaptureView.swift b/KVMConsoleiPad/Input/PointerCaptureView.swift index dbb2e26..f4e671e 100644 --- a/KVMConsoleiPad/Input/PointerCaptureView.swift +++ b/KVMConsoleiPad/Input/PointerCaptureView.swift @@ -85,6 +85,15 @@ final class PointerCaptureUIView: UIView, UIGestureRecognizerDelegate { // 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) + + // The only way to right-click from a bare touchscreen. A trackpad's two-finger tap already + // arrives as an indirect secondary click, so this one is restricted to direct touches to + // keep the two from both firing. A stationary two-finger tap starts neither the pan nor the + // pinch — both need movement — so wheel scrolling and zoom are unaffected. + let twoFingerTap = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryTap(_:))) + twoFingerTap.numberOfTouchesRequired = 2 + twoFingerTap.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)] + addGestureRecognizer(twoFingerTap) } required init?(coder: NSCoder) { diff --git a/KVMConsoleiPadTests/NanoKVMiPadTests.swift b/KVMConsoleiPadTests/NanoKVMiPadTests.swift index c5baecb..ac71bf2 100644 --- a/KVMConsoleiPadTests/NanoKVMiPadTests.swift +++ b/KVMConsoleiPadTests/NanoKVMiPadTests.swift @@ -101,6 +101,21 @@ final class KVMConsoleiPadTests: XCTestCase { ) } + /// The only way to right-click without a trackpad or mouse attached. + @MainActor + func test_twoFingerTapIsTheTouchscreenRightClick() { + let view = PointerCaptureUIView() + let taps = (view.gestureRecognizers ?? []).compactMap { $0 as? UITapGestureRecognizer } + let twoFinger = taps.first { $0.numberOfTouchesRequired == 2 } + + XCTAssertNotNil(twoFinger, "a bare touchscreen has no other way to send a secondary click") + XCTAssertEqual( + twoFinger?.allowedTouchTypes, + [NSNumber(value: UITouch.TouchType.direct.rawValue)], + "a trackpad two-finger tap already arrives as an indirect secondary click; both firing would double-click" + ) + } + // MARK: Synthesized tap dwell @MainActor diff --git a/KVMCore/Sources/KVMCore/UI/ViewerViewModel.swift b/KVMCore/Sources/KVMCore/UI/ViewerViewModel.swift index f46bb0e..132d255 100644 --- a/KVMCore/Sources/KVMCore/UI/ViewerViewModel.swift +++ b/KVMCore/Sources/KVMCore/UI/ViewerViewModel.swift @@ -36,6 +36,7 @@ public final class ViewerViewModel: ObservableObject { public init( device: Device, passwordStore: PasswordStore = KeychainPasswordStore(), + session injectedSession: (any KVMSession)? = nil, onConnected: ((Device.ID) -> Void)? = nil ) { let renderCoordinator = SampleBufferRenderCoordinator(renderMode: Self.renderMode(for: device.kvmType)) @@ -43,7 +44,7 @@ public final class ViewerViewModel: ObservableObject { self.passwordStore = passwordStore self.onConnected = onConnected self.renderCoordinator = renderCoordinator - self.session = KVMSessionFactory.make( + self.session = injectedSession ?? KVMSessionFactory.make( for: device, passwordStore: passwordStore, renderCoordinator: renderCoordinator diff --git a/KVMCore/Tests/KVMCoreTests/ViewerViewModelInputReleaseTests.swift b/KVMCore/Tests/KVMCoreTests/ViewerViewModelInputReleaseTests.swift new file mode 100644 index 0000000..57f0263 --- /dev/null +++ b/KVMCore/Tests/KVMCoreTests/ViewerViewModelInputReleaseTests.swift @@ -0,0 +1,94 @@ +import CoreGraphics +@testable import KVMCore +import XCTest + +/// `sendMouseReport` / `sendKeyboardReport` drop everything once capture is switched off. That is +/// right for ordinary input and wrong for a release: a button or key held at that moment would stay +/// pressed on the host forever. `sendInputRelease` is the deliberate bypass — these tests pin both +/// halves of that behaviour. +@MainActor +final class ViewerViewModelInputReleaseTests: XCTestCase { + func test_mouseReportIsDroppedWhileCaptureIsDisabled() { + let session = MockKVMSession() + let model = makeModel(session: session) + + model.isMouseCaptureEnabled = false + model.sendMouseReport(HIDMouseAbsoluteReport(buttons: 0x01, x: 10, y: 20)) + + XCTAssertTrue(session.mouseReports.isEmpty) + } + + func test_mouseReleaseReachesTheSessionWhileCaptureIsDisabled() { + let session = MockKVMSession() + let model = makeModel(session: session) + + model.isMouseCaptureEnabled = false + model.sendInputRelease(mouse: HIDMouseAbsoluteReport(buttons: 0, x: 10, y: 20)) + + XCTAssertEqual(session.mouseReports.count, 1) + XCTAssertEqual(session.mouseReports.first?.buttons, 0) + } + + func test_keyboardReportIsDroppedWhileCaptureIsDisabled() { + let session = MockKVMSession() + let model = makeModel(session: session) + + model.isKeyboardCaptureEnabled = false + model.sendKeyboardReport(HIDKeyboardReport(keycodes: [0x04])) + + XCTAssertTrue(session.keyboardReports.isEmpty) + } + + func test_keyboardReleaseReachesTheSessionWhileCaptureIsDisabled() { + let session = MockKVMSession() + let model = makeModel(session: session) + + model.isKeyboardCaptureEnabled = false + model.sendInputRelease(keyboard: HIDKeyboardReport(keycodes: [])) + + XCTAssertEqual(session.keyboardReports.count, 1) + XCTAssertEqual(session.keyboardReports.first?.keycodes, []) + } + + private func makeModel(session: MockKVMSession) -> ViewerViewModel { + // nanoKVMUSB is the one type that needs no password, so construction doesn't reach the + // keychain or stall on a password prompt. + ViewerViewModel( + device: Device(name: "Test", host: "127.0.0.1", kvmType: .nanoKVMUSB), + passwordStore: StubPasswordStore(), + session: session + ) + } +} + +@MainActor +private final class MockKVMSession: KVMSession { + var onStateChange: ((KVMSessionState) -> Void)? + var onVideoSize: ((CGSize?) -> Void)? + var onFlush: (() -> Void)? + var onHostStatusChange: ((KVMHostStatus?) -> Void)? + var state: KVMSessionState = .disconnected + var isStreaming = false + var powerControl: KVMPowerControl? + var hostStatus: KVMHostStatus? + + private(set) var mouseReports: [HIDMouseAbsoluteReport] = [] + private(set) var keyboardReports: [HIDKeyboardReport] = [] + + func connect(_ configuration: KVMSessionConfiguration) {} + func disconnect(updateState: Bool) {} + + func sendKeyboardReport(_ report: HIDKeyboardReport) { + keyboardReports.append(report) + } + + func sendMouseReport(_ report: HIDMouseAbsoluteReport) { + mouseReports.append(report) + } +} + +private struct StubPasswordStore: PasswordStore { + func password(for account: String) throws -> String? { nil } + func savePassword(_ password: String, for account: String) throws {} + func deletePassword(for account: String) throws {} +} diff --git a/project.yml b/project.yml index 034150d..ed772ee 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.5" + MARKETING_VERSION: "1.0.6" CURRENT_PROJECT_VERSION: "1" targets: KVMConsole: