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: