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
71 changes: 71 additions & 0 deletions GestureHarness/App/GestureHarnessApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import KVMCore
import SwiftUI

/// A host for UI tests that need real multi-touch against `PointerCaptureView`.
///
/// The gesture recognizers in `PointerCaptureUIView` arbitrate against each other, and that
/// arbitration cannot be exercised from a unit test — synthesizing touches needs a running app.
/// This target exists only so `GestureHarnessUITests` has somewhere to tap. It is never shipped.
@main
struct GestureHarnessApp: App {
var body: some Scene {
WindowGroup {
GestureHarnessView()
}
}
}

struct GestureHarnessView: View {
@StateObject private var zoom = ViewerZoomState()
// A click is a press followed by a release 50ms later, so the *last* report is always the
// release. The presses are what the tests are asking about, so keep those.
@State private var presses: [Int] = []
@State private var wheelNotches = 0
@State private var reportCount = 0

/// Square so the aspect-fit rect is predictable regardless of the simulator's screen size.
private let videoSize = CGSize(width: 1000, height: 1000)

var body: some View {
ZStack {
Color.black

PointerCaptureView(
isEnabled: true,
isScrollInverted: false,
videoSize: videoSize,
zoom: zoom,
onMouseReport: { report in record(report) },
onMouseRelease: { report in record(report) }
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("pointerCapture")
}
.ignoresSafeArea()
.overlay(alignment: .top) {
// The UI test reads this rather than the view hierarchy: gesture output is a stream of
// reports, not view state, so it has to be surfaced somewhere XCUITest can see it.
Text(stateDescription)
.accessibilityIdentifier("state")
.foregroundStyle(.white)
.font(.caption)
.padding(6)
}
}

private func record(_ report: HIDMouseAbsoluteReport) {
reportCount += 1
if report.buttons != 0 {
presses.append(Int(report.buttons))
}
if report.wheel != 0 {
wheelNotches += 1
}
}

private var stateDescription: String {
let pressList = presses.map(String.init).joined(separator: ",")
let scale = String(format: "%.2f", zoom.scale)
return "presses=[\(pressList)] wheel=\(wheelNotches) count=\(reportCount) scale=\(scale)"
}
}
67 changes: 67 additions & 0 deletions GestureHarnessUITests/PointerGestureTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import XCTest

/// Real multi-touch against the real recognizers. Unit tests can assert how `PointerCaptureUIView`
/// is *configured*, but not which recognizer wins when several want the same touches — and that
/// arbitration is what broke the two-finger right click in 1.0.6.
final class PointerGestureTests: XCTestCase {
private var app: XCUIApplication!

override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}

func test_oneFingerTapSendsThePrimaryButton() {
let surface = app.otherElements["pointerCapture"]
XCTAssertTrue(surface.waitForExistence(timeout: 10))

surface.tap()

XCTAssertTrue(waitForState(containing: "presses=[1]"), "one-finger tap should press the left button, got \(stateText)")
}

func test_twoFingerTapSendsTheSecondaryButton() {
let surface = app.otherElements["pointerCapture"]
XCTAssertTrue(surface.waitForExistence(timeout: 10))

surface.tap(withNumberOfTaps: 1, numberOfTouches: 2)

XCTAssertTrue(
waitForState(containing: "presses=[2]"),
"two-finger tap should press the right button, got \(stateText)"
)
}

func test_pinchZoomsWithoutSendingAClick() {
let surface = app.otherElements["pointerCapture"]
XCTAssertTrue(surface.waitForExistence(timeout: 10))

surface.pinch(withScale: 3, velocity: 3)

XCTAssertTrue(waitForState(satisfying: { !$0.contains("scale=1.00") }), "pinch should zoom, got \(stateText)")
XCTAssertTrue(
stateText.contains("presses=[]"),
"a pinch must not also fire a click, got \(stateText)"
)
}

private var stateText: String {
app.staticTexts["state"].label
}

private func waitForState(containing needle: String) -> Bool {
waitForState(satisfying: { $0.contains(needle) })
}

private func waitForState(satisfying predicate: @escaping (String) -> Bool) -> Bool {
let element = app.staticTexts["state"]
let matches = NSPredicate { object, _ in
guard let element = object as? XCUIElement else { return false }
return predicate(element.label)
}
let expectation = XCTNSPredicateExpectation(predicate: matches, object: element)
return XCTWaiter().wait(for: [expectation], timeout: 5) == .completed
}
}
27 changes: 25 additions & 2 deletions KVMConsoleiPad/Input/PointerCaptureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ final class PointerCaptureUIView: UIView, UIGestureRecognizerDelegate {
private let clickSequencer = SynthesizedTapSequencer()
private weak var pinchRecognizer: UIPinchGestureRecognizer?
private weak var pointerPanRecognizer: UIPanGestureRecognizer?
private weak var twoFingerTapRecognizer: UITapGestureRecognizer?
private var pinchAnchorVideo: CGPoint?

override init(frame: CGRect) {
Expand Down Expand Up @@ -88,12 +89,12 @@ final class PointerCaptureUIView: UIView, UIGestureRecognizerDelegate {

// 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.
// keep the two from both firing.
let twoFingerTap = UITapGestureRecognizer(target: self, action: #selector(handleSecondaryTap(_:)))
twoFingerTap.numberOfTouchesRequired = 2
twoFingerTap.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
addGestureRecognizer(twoFingerTap)
twoFingerTapRecognizer = twoFingerTap
}

required init?(coder: NSCoder) {
Expand Down Expand Up @@ -293,10 +294,32 @@ final class PointerCaptureUIView: UIView, UIGestureRecognizerDelegate {
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
) -> Bool {
// A pinch and the two-finger tap must be mutually exclusive, or spreading two fingers
// zooms *and* fires a right click — the tap's movement tolerance is measured loosely
// enough that a symmetric spread still looks stationary to it. A pinch begins as soon as
// the fingers move, which cancels the tap; a tap that stays still never starts a pinch.
if let twoFingerTapRecognizer,
gestureRecognizer === twoFingerTapRecognizer || other === twoFingerTapRecognizer {
return false
}
// Pinch should coexist with the pointer pan so two-finger zoom/pan doesn't fail-cancel
// the pan's wheel-scroll tracking (we suppress wheel emission while pinch is active).
return true
}

func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf other: UIGestureRecognizer
) -> Bool {
// The pan claims two touches the moment they land and begins before the tap can complete,
// which is why the two-finger tap never fired. Making it wait for the tap to fail costs
// nothing in practice: any real scroll moves the fingers, which fails the tap at about the
// threshold the pan needs anyway. The pinch is deliberately NOT here — making it wait
// starves it entirely, so a spread fires a right click instead of zooming. Pinch is kept
// off the tap by the mutual-exclusion rule above instead.
guard let twoFingerTapRecognizer, other === twoFingerTapRecognizer else { return false }
return gestureRecognizer === pointerPanRecognizer
}
}

enum PointerDragButtonResolver {
Expand Down
39 changes: 39 additions & 0 deletions KVMConsoleiPadTests/NanoKVMiPadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,45 @@ final class KVMConsoleiPadTests: XCTestCase {
)
}

/// The two-finger tap has to be arbitrated against pan and pinch differently, and the
/// difference was established by running real multi-touch through `GestureHarnessUITests`:
///
/// - The **pan** claims two touches the moment they land and begins before the tap can
/// complete, so it must wait for the tap to fail. That is what makes the tap fire at all.
/// - The **pinch** must NOT wait: making it wait starves it completely, so a spread fires a
/// right click instead of zooming. It is kept off the tap by mutual exclusion instead —
/// a pinch begins as soon as the fingers move, which cancels the tap.
@MainActor
func test_twoFingerTapArbitratesAgainstPanAndPinchDifferently() {
let view = PointerCaptureUIView()
let recognizers = view.gestureRecognizers ?? []
let taps = recognizers.compactMap { $0 as? UITapGestureRecognizer }
guard
let twoFinger = taps.first(where: { $0.numberOfTouchesRequired == 2 }),
let pinch = recognizers.compactMap({ $0 as? UIPinchGestureRecognizer }).first,
let pan = recognizers.compactMap({ $0 as? UIPanGestureRecognizer }).first
else {
return XCTFail("expected a two-finger tap, a pinch and a pan on the capture view")
}

XCTAssertTrue(
view.gestureRecognizer(pan, shouldRequireFailureOf: twoFinger),
"without this the pan begins first and the two-finger tap never fires"
)
XCTAssertFalse(
view.gestureRecognizer(pinch, shouldRequireFailureOf: twoFinger),
"making the pinch wait starves it: a spread fires a right click instead of zooming"
)
XCTAssertFalse(
view.gestureRecognizer(pinch, shouldRecognizeSimultaneouslyWith: twoFinger),
"otherwise a spread both zooms and fires a right click"
)
XCTAssertTrue(
view.gestureRecognizer(pinch, shouldRecognizeSimultaneouslyWith: pan),
"pinch and pan still coexist so zooming doesn't fail-cancel wheel tracking"
)
}

// MARK: Synthesized tap dwell

@MainActor
Expand Down
44 changes: 43 additions & 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.6"
MARKETING_VERSION: "1.0.7"
CURRENT_PROJECT_VERSION: "1"
targets:
KVMConsole:
Expand Down Expand Up @@ -122,6 +122,38 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
GENERATE_INFOPLIST_FILE: NO
GestureHarness:
# UI-test host only — never shipped. Exists so GestureHarnessUITests can drive real
# multi-touch against PointerCaptureView; recognizer arbitration cannot be unit tested.
type: application
platform: iOS
sources:
- GestureHarness
# The real files under test, compiled into the harness — not a copy of them.
- KVMConsoleiPad/Input/PointerCaptureView.swift
- KVMConsoleiPad/Input/SynthesizedTapSequencer.swift
dependencies:
- package: KVMCore
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: io.lyx.KVMConsole.GestureHarness
TARGETED_DEVICE_FAMILY: "2"
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
SWIFT_STRICT_CONCURRENCY: complete
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
GestureHarnessUITests:
type: bundle.ui-testing
platform: iOS
sources:
- GestureHarnessUITests
dependencies:
- target: GestureHarness
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: io.lyx.GestureHarnessUITests
GENERATE_INFOPLIST_FILE: YES
TEST_TARGET_NAME: GestureHarness
KVMConsoleiPadTests:
type: bundle.unit-test
platform: iOS
Expand Down Expand Up @@ -151,3 +183,13 @@ schemes:
test:
targets:
- KVMConsoleiPadTests
GestureHarness:
# Deliberately separate from the KVMConsoleiPad scheme: UI tests are slow and flakier than
# unit tests, so they run on demand rather than on every PR.
build:
targets:
GestureHarness: all
GestureHarnessUITests: [test]
test:
targets:
- GestureHarnessUITests
Loading