Skip to content
Merged
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
9 changes: 9 additions & 0 deletions KVMConsoleiPad/Input/PointerCaptureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions KVMConsoleiPadTests/NanoKVMiPadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion KVMCore/Sources/KVMCore/UI/ViewerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ 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))
self.device = device
self.passwordStore = passwordStore
self.onConnected = onConnected
self.renderCoordinator = renderCoordinator
self.session = KVMSessionFactory.make(
self.session = injectedSession ?? KVMSessionFactory.make(
for: device,
passwordStore: passwordStore,
renderCoordinator: renderCoordinator
Expand Down
94 changes: 94 additions & 0 deletions KVMCore/Tests/KVMCoreTests/ViewerViewModelInputReleaseTests.swift
Original file line number Diff line number Diff line change
@@ -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 {}
}
2 changes: 1 addition & 1 deletion project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading