diff --git a/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift b/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift new file mode 100644 index 0000000..37bf9af --- /dev/null +++ b/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift @@ -0,0 +1,51 @@ +import Combine +import GameController + +/// Tracks whether a hardware keyboard is currently attached to the device. +/// +/// The on-screen modifier-key bar exists so that touch/on-screen-keyboard users +/// can send Ctrl/Alt/Cmd/etc. When a physical keyboard is connected those keys +/// are already available, so the bar is redundant and the viewer hides it. +@MainActor +final class PhysicalKeyboardObserver: ObservableObject { + @Published private(set) var isConnected: Bool + + private var observers: [NSObjectProtocol] = [] + + init() { + isConnected = GCKeyboard.coalesced != nil + + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: .GCKeyboardDidConnect, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.isConnected = true } + }) + observers.append(center.addObserver( + forName: .GCKeyboardDidDisconnect, + object: nil, + queue: .main + ) { [weak self] _ in + // A disconnect notification can fire while another keyboard is still + // attached, so re-read the coalesced keyboard rather than assuming none. + MainActor.assumeIsolated { self?.isConnected = GCKeyboard.coalesced != nil } + }) + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } +} + +/// Resolves whether the modifier-key bar should be visible. Kept as a pure +/// function so the precedence rule (hardware keyboard wins over the user toggle) +/// is unit-testable without a live `GCKeyboard`. +enum ModifierBarVisibility { + static func shouldShow(userEnabled: Bool, physicalKeyboardConnected: Bool) -> Bool { + userEnabled && !physicalKeyboardConnected + } +} diff --git a/KVMConsoleiPad/UI/ViewerView.swift b/KVMConsoleiPad/UI/ViewerView.swift index 62486e6..dc8420d 100644 --- a/KVMConsoleiPad/UI/ViewerView.swift +++ b/KVMConsoleiPad/UI/ViewerView.swift @@ -33,6 +33,7 @@ struct ViewerHostView: View { struct ViewerView: View { @StateObject private var model: ViewerViewModel + @StateObject private var keyboardObserver = PhysicalKeyboardObserver() @State private var modifierState = ModifierKeyState() @State private var showModifierBar = true @State private var keyboardFocusToken = 0 @@ -54,7 +55,7 @@ struct ViewerView: View { errorOverlay } - if showModifierBar, model.passwordPrompt == nil, !showsErrorOverlay { + if modifierBarVisible, model.passwordPrompt == nil, !showsErrorOverlay { ModifierKeyBar(state: $modifierState) { usage, modifier in sendVirtualKey(usage: usage, modifier: modifier) } @@ -120,6 +121,13 @@ struct ViewerView: View { } } + private var modifierBarVisible: Bool { + ModifierBarVisibility.shouldShow( + userEnabled: showModifierBar, + physicalKeyboardConnected: keyboardObserver.isConnected + ) + } + private var showsLocalCursor: Bool { model.isStreaming && model.isMouseCaptureEnabled @@ -171,7 +179,10 @@ struct ViewerView: View { } .toggleStyle(.button) .labelStyle(.iconOnly) - .help(showModifierBar ? "Hide modifier-key bar" : "Show modifier-key bar") + .disabled(keyboardObserver.isConnected) + .help(keyboardObserver.isConnected + ? "Hardware keyboard connected — modifier-key bar hidden" + : (showModifierBar ? "Hide modifier-key bar" : "Show modifier-key bar")) Button { keyboardFocusToken += 1 diff --git a/KVMConsoleiPadTests/NanoKVMiPadTests.swift b/KVMConsoleiPadTests/NanoKVMiPadTests.swift index 5dbc546..2baf3ba 100644 --- a/KVMConsoleiPadTests/NanoKVMiPadTests.swift +++ b/KVMConsoleiPadTests/NanoKVMiPadTests.swift @@ -58,4 +58,22 @@ final class KVMConsoleiPadTests: XCTestCase { XCTAssertFalse(PointerScrollResolver.shouldEmitWheel(touchCount: 1)) XCTAssertTrue(PointerScrollResolver.shouldEmitWheel(touchCount: 2)) } + + func test_modifierBarHiddenWhenPhysicalKeyboardConnected() { + XCTAssertFalse( + ModifierBarVisibility.shouldShow(userEnabled: true, physicalKeyboardConnected: true) + ) + } + + func test_modifierBarVisibleWhenEnabledAndNoPhysicalKeyboard() { + XCTAssertTrue( + ModifierBarVisibility.shouldShow(userEnabled: true, physicalKeyboardConnected: false) + ) + } + + func test_modifierBarHiddenWhenUserDisablesIt() { + XCTAssertFalse( + ModifierBarVisibility.shouldShow(userEnabled: false, physicalKeyboardConnected: false) + ) + } }