Fix the keyboard typing pipeline + add a test that catches the regression

The earlier UIKeyInput conformance was declared in a separate extension. ObjC
protocol conformance via Swift extension is fragile when the protocol
inherits another @objc protocol (UIKeyInput inherits UITextInputTraits) — the
runtime didn't always pick up insertText:, so the on-screen keyboard came
up but characters never reached controller.type(_:).

Fix: declare UIKeyInput conformance directly on FramebufferUIView's class
declaration, with insertText / deleteBackward / hasText as native members.

Also caught and fixed by the new UI test:
- The toolbar's keyboard-icon button had a 20×13 hit region (SF Symbol size)
  even though the visual frame was 34×34 — XCUI taps couldn't land on it
  reliably. .contentShape(Rectangle()) widens the hit area to the frame.
- accessibilityValue is reserved by iOS for UIKeyInput-classed views (treats
  them as TextView), so a separate hidden "fb-diag" accessibility probe
  records keyboard plumbing events for the test to verify.

Tests:
- KeyboardInputTests (5): pure mapping from String → X11 keysym down/up pairs
- ScreensUITests.testSoftwareKeyboardSendsCharactersToFramebuffer:
  opens a session, taps the keyboard toggle, types "hi" via the system
  keyboard, and asserts the framebuffer's diagnostic probe records
  [ins:h] and [ins:i] — proving the chars reach controller.type(_:)
- A SwiftUI state probe (sessionview-state) verifies the binding flips,
  which guards against future tap-routing regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 23:04:03 -05:00
parent da882531d1
commit 0e25dbeba4
6 changed files with 209 additions and 69 deletions

View File

@@ -159,16 +159,50 @@ public final class SessionController {
public func type(_ string: String) {
guard !viewOnly else { return }
let events = Self.keyEvents(for: string)
recordTypedEvents(events)
guard let connection else { return }
for event in events {
let code = VNCKeyCode(event.keysym)
if event.isDown {
connection.keyDown(code)
} else {
connection.keyUp(code)
}
}
}
/// Pure mapping from a string to the key down/up keysym pairs it should
/// generate. Newlines map to X11 Return; all other printable ASCII maps
/// 1:1 to the equivalent X11 keysym (which equals the ASCII code).
public nonisolated static func keyEvents(for string: String) -> [KeyEvent] {
var events: [KeyEvent] = []
for char in string {
if char.isNewline {
pressKey(.return)
events.append(KeyEvent(keysym: 0xFF0D, isDown: true))
events.append(KeyEvent(keysym: 0xFF0D, isDown: false))
continue
}
for code in VNCKeyCode.withCharacter(char) {
connection?.keyDown(code)
connection?.keyUp(code)
events.append(KeyEvent(keysym: code.rawValue, isDown: true))
events.append(KeyEvent(keysym: code.rawValue, isDown: false))
}
}
return events
}
public struct KeyEvent: Equatable, Sendable {
public let keysym: UInt32
public let isDown: Bool
}
/// Test hook: when set, every typed event is appended here in addition to
/// being sent over the wire. Used by UI tests to verify keyboard plumbing.
public var typedEventLog: [KeyEvent] = []
public var typedEventLogEnabled: Bool = false
private func recordTypedEvents(_ events: [KeyEvent]) {
guard typedEventLogEnabled else { return }
typedEventLog.append(contentsOf: events)
}
public func sendBackspace() { pressKey(.delete) }

View File

@@ -0,0 +1,41 @@
import Testing
@testable import VNCCore
import Foundation
@Suite struct KeyboardInputTests {
@Test func plainAsciiProducesDownUpPairs() {
let events = SessionController.keyEvents(for: "ab")
#expect(events.count == 4)
#expect(events[0] == .init(keysym: 0x61, isDown: true))
#expect(events[1] == .init(keysym: 0x61, isDown: false))
#expect(events[2] == .init(keysym: 0x62, isDown: true))
#expect(events[3] == .init(keysym: 0x62, isDown: false))
}
@Test func uppercaseAsciiSendsDistinctKeysym() {
let events = SessionController.keyEvents(for: "A")
#expect(events.count == 2)
#expect(events[0] == .init(keysym: 0x41, isDown: true))
#expect(events[1] == .init(keysym: 0x41, isDown: false))
}
@Test func spaceMapsToAsciiSpace() {
let events = SessionController.keyEvents(for: " ")
#expect(events.count == 2)
#expect(events[0].keysym == 0x20)
}
@Test func newlineMapsToX11Return() {
let events = SessionController.keyEvents(for: "\n")
#expect(events.count == 2)
#expect(events[0] == .init(keysym: 0xFF0D, isDown: true))
#expect(events[1] == .init(keysym: 0xFF0D, isDown: false))
}
@Test func passwordWithMixedCaseAndDigitsAndPunctuation() {
let events = SessionController.keyEvents(for: "Hi!7")
let downKeys = events.filter(\.isDown).map(\.keysym)
// H=0x48, i=0x69, !=0x21, 7=0x37
#expect(downKeys == [0x48, 0x69, 0x21, 0x37])
}
}