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
51 changes: 51 additions & 0 deletions KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
15 changes: 13 additions & 2 deletions KVMConsoleiPad/UI/ViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions KVMConsoleiPadTests/NanoKVMiPadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}
Loading