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
2 changes: 1 addition & 1 deletion THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The hardware button constants in `IndigoHID.swift` come from the same header:
| `edgeValue`, none `0`, top `1`, left `2`, bottom `3`, right `4` | `IndigoHIDEdgeNone` through `IndigoHIDEdgeRight` |

The eventMask bits idb documents for each edge, and its observation that the guest recognises a
system edge gesture from those bits, are recorded in `docs/private-api-notes.md`. Only the bottom
system edge gesture from those bits, were measured on a device rather than taken on trust. Only the bottom
value was found to change behaviour on Xcode 26.5.

### Siniulator
Expand Down
22 changes: 22 additions & 0 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,28 @@ struct ODHubViewer: ParsableCommand {
}
}
},
toggleKeyboardInput: { enabled in
for udid in manager.openUDIDs {
manager.controller(for: udid)?.sendsKeyboardInput = enabled
}
},
toggleHardwareKeyboard: { enabled in
for udid in manager.openUDIDs {
runOnEveryDevice("hardware keyboard", udid) {
try adapter.setHardwareKeyboardEnabled(enabled, udid: $0)
}
}
},
matchKeyboardLanguage: { matching in
// Off leaves the guest on whatever it had: there is no "stop matching" call, so
// turning it back on is what re-applies the Mac's language.
guard matching, let language = KeyboardLanguage.current() else { return }
for udid in manager.openUDIDs {
runOnEveryDevice("keyboard language", udid) {
try adapter.setKeyboardLanguage(language, udid: $0)
}
}
},
setOrientation: { orientation in
for udid in manager.openUDIDs {
guard let controller = manager.controller(for: udid) else { continue }
Expand Down
2 changes: 2 additions & 0 deletions engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public struct Capabilities: OptionSet, Sendable, Hashable {
public static let pointer = Capabilities(rawValue: 1 << 9)
public static let rotation = Capabilities(rawValue: 1 << 10)
public static let deviceNotifications = Capabilities(rawValue: 1 << 11)
public static let hardwareKeyboard = Capabilities(rawValue: 1 << 12)
}

extension Capabilities {
Expand All @@ -26,6 +27,7 @@ extension Capabilities {
("multiTouch", .multiTouch),
("keyboard", .keyboard),
("hardwareButtons", .hardwareButtons),
("hardwareKeyboard", .hardwareKeyboard),
("memoryWarning", .memoryWarning),
("slowAnimations", .slowAnimations),
("shake", .shake),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,75 @@ public final class CoreSimulatorAdapter: SimulatorAdapter, @unchecked Sendable {
device.simulateMemoryWarning()
}

/// The Mac's keyboard as the device's hardware keyboard.
///
/// Two private pieces, both confirmed present on this Xcode before use: the selector on
/// `SimDevice`, and `IndigoHIDGetKeyboardType` for the byte it wants. The type is asked for
/// rather than guessed, because passing a made up keyboard type is not a safe thing to do to a
/// device.
public func setHardwareKeyboardEnabled(_ enabled: Bool, udid: String) throws {
lock.lock()
defer { lock.unlock() }

let device = try rawDevice(udid)
guard DeviceState.from(state: device.state, stateString: device.stateString ?? "") == .booted else {
throw EngineError.deviceNotBooted(udid: udid)
}
let selector = NSSelectorFromString("setHardwareKeyboardEnabled:keyboardType:error:")
guard (device as AnyObject).responds(to: selector) else {
throw EngineError.symbolNotFound(
name: "-[SimDevice setHardwareKeyboardEnabled:keyboardType:error:]",
framework: PrivateFramework.coreSimulator.rawValue
)
}
let simulatorKit = try FrameworkLoader.load(.simulatorKit, from: xcode)
guard let symbol = simulatorKit.symbol(named: "IndigoHIDGetKeyboardType") else {
throw EngineError.symbolNotFound(
name: "IndigoHIDGetKeyboardType",
framework: PrivateFramework.simulatorKit.rawValue
)
}
typealias KeyboardType = @convention(c) () -> UInt8
let keyboardType = unsafeBitCast(symbol, to: KeyboardType.self)()

// Returns BOOL with an NSError out parameter, so Swift imports it as throwing. Rewrapped
// rather than passed through, so callers see one error type.
do {
try device.setHardwareKeyboardEnabled(enabled, keyboardType: keyboardType)
} catch {
throw EngineError.privateCall(
symbol: "setHardwareKeyboardEnabled:keyboardType:error:",
message: error.localizedDescription
)
}
}

/// Points the guest's keyboard at a language, for example "en-US".
public func setKeyboardLanguage(_ language: String, udid: String) throws {
lock.lock()
defer { lock.unlock() }

let device = try rawDevice(udid)
guard DeviceState.from(state: device.state, stateString: device.stateString ?? "") == .booted else {
throw EngineError.deviceNotBooted(udid: udid)
}
let selector = NSSelectorFromString("setKeyboardLanguage:error:")
guard (device as AnyObject).responds(to: selector) else {
throw EngineError.symbolNotFound(
name: "-[SimDevice setKeyboardLanguage:error:]",
framework: PrivateFramework.coreSimulator.rawValue
)
}
do {
try device.setKeyboardLanguage(language)
} catch {
throw EngineError.privateCall(
symbol: "setKeyboardLanguage:error:",
message: error.localizedDescription
)
}
}

public func setOrientation(_ orientation: DeviceOrientation, udid: String) throws {
lock.lock()
defer { lock.unlock() }
Expand Down Expand Up @@ -232,6 +301,15 @@ public final class CoreSimulatorAdapter: SimulatorAdapter, @unchecked Sendable {
class_getInstanceMethod(device, NSSelectorFromString("lookup:error:")) != nil {
capabilities.insert(.rotation)
}
// Both halves have to be there: the selector that makes the change, and the symbol that
// supplies the keyboard type it wants. One without the other is not a working capability.
if hasSimulatorKit,
let device = NSClassFromString("SimDevice"),
class_getInstanceMethod(
device, NSSelectorFromString("setHardwareKeyboardEnabled:keyboardType:error:")
) != nil {
capabilities.insert(.hardwareKeyboard)
}
if let deviceSet = NSClassFromString("SimDeviceSet"),
class_getInstanceMethod(
deviceSet, NSSelectorFromString("registerNotificationHandlerOnQueue:handler:")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ public protocol SimulatorAdapter: Sendable {
/// Turns the device itself, which makes the guest re-lay out. The viewer still has to turn its
/// own view to match, since the framebuffer stays portrait native.
func setOrientation(_ orientation: DeviceOrientation, udid: String) throws
/// Connects or disconnects the Mac's keyboard as the device's hardware keyboard.
func setHardwareKeyboardEnabled(_ enabled: Bool, udid: String) throws
/// Points the guest's keyboard at a language, for example "en-US".
func setKeyboardLanguage(_ language: String, udid: String) throws
/// Watches every device in the set, so a window learns that its device has gone or come back
/// without asking.
func watchDeviceStates() throws -> any DeviceNotifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
/// Returns a mach port for a service in the device's own namespace, or 0. Used to reach the
/// guest's workspace port, which is what carries orientation.
- (unsigned int)lookup:(NSString *_Nonnull)service error:(NSError *_Nullable *_Nullable)error;
/// Connects or disconnects the Mac's keyboard as the device's hardware keyboard. `keyboardType`
/// comes from `IndigoHIDGetKeyboardType`. Verified present on Xcode 27 (27A266a).
- (BOOL)setHardwareKeyboardEnabled:(BOOL)enabled
keyboardType:(unsigned char)keyboardType
error:(NSError *_Nullable *_Nullable)error;
/// Sets the guest's keyboard language, for example "en-US". Verified present on Xcode 27 (27A266a).
- (BOOL)setKeyboardLanguage:(NSString *_Nonnull)language
error:(NSError *_Nullable *_Nullable)error;
@end

@protocol ODHSimDeviceSet <NSObject>
Expand Down
8 changes: 6 additions & 2 deletions engine/Sources/OpenDeviceHubViewer/DeviceScreenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,20 @@ public final class DeviceScreenView: MTKView, NSDraggingSource {
return false
}

/// Off sends keystrokes nowhere, so the Mac keeps them. Useful when typing a shortcut that the
/// guest would otherwise swallow.
public var sendsKeyboardInput = true

public override func keyDown(with event: NSEvent) {
guard let usage = KeyboardMap.usage(forVirtualKeyCode: event.keyCode) else {
guard sendsKeyboardInput, let usage = KeyboardMap.usage(forVirtualKeyCode: event.keyCode) else {
super.keyDown(with: event)
return
}
onKey?(usage, true)
}

public override func keyUp(with event: NSEvent) {
guard let usage = KeyboardMap.usage(forVirtualKeyCode: event.keyCode) else {
guard sendsKeyboardInput, let usage = KeyboardMap.usage(forVirtualKeyCode: event.keyCode) else {
super.keyUp(with: event)
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate
}

/// The most recent frame as a PNG, matching whatever the window is showing including the bezel.
/// Whether the Mac's keystrokes reach this device.
public var sendsKeyboardInput: Bool {
get { screenView.sendsKeyboardInput }
set { screenView.sendsKeyboardInput = newValue }
}

public func screenshotPNG() -> Data? {
guard let surface = renderer.currentSurface else { return nil }
// With the body shown, a screenshot means the device, not just its screen. Without it, the
Expand Down
22 changes: 22 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/KeyboardLanguage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Carbon.HIToolbox
import Foundation

/// The language the Mac's keyboard is currently typing in.
///
/// The input source rather than the locale: someone in Lagos typing on a French keyboard wants the
/// guest set to French, and their locale would say otherwise.
public enum KeyboardLanguage {
public static func current() -> String? {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceLanguages) else {
return fallback()
}
let languages = Unmanaged<CFArray>.fromOpaque(raw).takeUnretainedValue() as? [String]
return languages?.first ?? fallback()
}

/// A keyboard with no language of its own, which some input sources genuinely have.
private static func fallback() -> String? {
Locale.current.language.languageCode?.identifier
}
}
69 changes: 69 additions & 0 deletions engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public enum ViewerMenu {
public var triggerICloudSync: () -> Void
public var setLocation: (SimctlService.LocationScenario?) -> Void
public var setCustomLocation: () -> Void
public var toggleKeyboardInput: (Bool) -> Void
public var toggleHardwareKeyboard: (Bool) -> Void
public var matchKeyboardLanguage: (Bool) -> Void
public var setOrientation: (DeviceOrientation) -> Void
public var appSwitcher: () -> Void
public var stopRecording: () -> Void
Expand Down Expand Up @@ -77,6 +80,9 @@ public enum ViewerMenu {
triggerICloudSync: @escaping () -> Void,
setLocation: @escaping (SimctlService.LocationScenario?) -> Void,
setCustomLocation: @escaping () -> Void,
toggleKeyboardInput: @escaping (Bool) -> Void,
toggleHardwareKeyboard: @escaping (Bool) -> Void,
matchKeyboardLanguage: @escaping (Bool) -> Void,
setOrientation: @escaping (DeviceOrientation) -> Void,
appSwitcher: @escaping () -> Void,
stopRecording: @escaping () -> Void,
Expand Down Expand Up @@ -107,6 +113,9 @@ public enum ViewerMenu {
self.triggerICloudSync = triggerICloudSync
self.setLocation = setLocation
self.setCustomLocation = setCustomLocation
self.toggleKeyboardInput = toggleKeyboardInput
self.toggleHardwareKeyboard = toggleHardwareKeyboard
self.matchKeyboardLanguage = matchKeyboardLanguage
self.setOrientation = setOrientation
self.appSwitcher = appSwitcher
self.stopRecording = stopRecording
Expand Down Expand Up @@ -260,6 +269,44 @@ public enum ViewerMenu {

let ioItem = NSMenuItem()
let ioMenu = NSMenu(title: "I/O")

let inputItem = NSMenuItem(title: "Input", action: nil, keyEquivalent: "")
let inputMenu = NSMenu(title: "Input")
let sendKeys = target.item(
"Send Keyboard Input to Device",
#selector(MenuTarget.keyboardInput(_:)),
"k",
[.command, .option]
)
sendKeys.state = .on
inputMenu.addItem(sendKeys)
inputItem.submenu = inputMenu
ioMenu.addItem(inputItem)

let keyboardItem = NSMenuItem(title: "Keyboard", action: nil, keyEquivalent: "")
let keyboardMenu = NSMenu(title: "Keyboard")
let hardware = target.item(
"Connect Hardware Keyboard",
#selector(MenuTarget.hardwareKeyboard(_:)),
"k",
[.command, .shift]
)
hardware.state = .on
disable(hardware, unless: capabilities.contains(.hardwareKeyboard), reason: "not available on this Xcode")
let sameLanguage = target.item(
"Use the Same Keyboard Language as macOS",
#selector(MenuTarget.matchKeyboardLanguage(_:)),
"",
[]
)
sameLanguage.state = .on
disable(sameLanguage, unless: capabilities.contains(.hardwareKeyboard), reason: "not available on this Xcode")
keyboardMenu.addItem(sameLanguage)
keyboardMenu.addItem(hardware)
keyboardItem.submenu = keyboardMenu
ioMenu.addItem(keyboardItem)
ioMenu.addItem(.separator())

ioMenu.addItem(target.item("Increase Volume", #selector(MenuTarget.volumeUp), String(UnicodeScalar(NSUpArrowFunctionKey)!), [.command]))
ioMenu.addItem(target.item("Decrease Volume", #selector(MenuTarget.volumeDown), String(UnicodeScalar(NSDownArrowFunctionKey)!), [.command]))
ioItem.submenu = ioMenu
Expand Down Expand Up @@ -401,6 +448,9 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation {
private var isSlowAnimations = false
private var isLatencyVisible = false
private var isIncreasedContrast = false
private var sendsKeyboardInput = true
private var hasHardwareKeyboard = true
private var matchesKeyboardLanguage = true

init(actions: ViewerMenu.Actions, commandLineTool: CommandLineToolMenu? = nil) {
self.actions = actions
Expand Down Expand Up @@ -497,6 +547,25 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation {
@objc func clearLocation() { actions.setLocation(nil) }
@objc func customLocation() { actions.setCustomLocation() }

/// Both start on, because that is what the app does before anyone touches the menu.
@objc func keyboardInput(_ sender: NSMenuItem) {
sendsKeyboardInput.toggle()
sender.state = sendsKeyboardInput ? .on : .off
actions.toggleKeyboardInput(sendsKeyboardInput)
}

@objc func matchKeyboardLanguage(_ sender: NSMenuItem) {
matchesKeyboardLanguage.toggle()
sender.state = matchesKeyboardLanguage ? .on : .off
actions.matchKeyboardLanguage(matchesKeyboardLanguage)
}

@objc func hardwareKeyboard(_ sender: NSMenuItem) {
hasHardwareKeyboard.toggle()
sender.state = hasHardwareKeyboard ? .on : .off
actions.toggleHardwareKeyboard(hasHardwareKeyboard)
}

@objc func locationScenario(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String,
let scenario = SimctlService.LocationScenario(rawValue: raw) else { return }
Expand Down
4 changes: 2 additions & 2 deletions engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import OpenDeviceHubEngine
/// The handful of choices worth remembering between launches.
///
/// Stored through the same seam as the window frames, so tests never leave a plist behind. Each
/// default is the behaviour the app already had, except `shutdownOnWindowClose`, which is called out
/// in `docs/DECISIONS.md`.
/// default is the behaviour the app already had, except `shutdownOnWindowClose`, which deliberately
/// changed it to match the simulator this replaces.
public struct ViewerSettings: Sendable {
private let storage: any PreferenceStorage
private let prefix: String
Expand Down
Loading