diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2213f56..ade1fbb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 9857335..b5ed001 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -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 } diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift index a651ccf..be5c28c 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/Capabilities.swift @@ -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 { @@ -26,6 +27,7 @@ extension Capabilities { ("multiTouch", .multiTouch), ("keyboard", .keyboard), ("hardwareButtons", .hardwareButtons), + ("hardwareKeyboard", .hardwareKeyboard), ("memoryWarning", .memoryWarning), ("slowAnimations", .slowAnimations), ("shake", .shake), diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift index 496e204..8269eb9 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/CoreSimulatorAdapter.swift @@ -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() } @@ -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:") diff --git a/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift b/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift index f4624a3..dc5e203 100644 --- a/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift +++ b/engine/Sources/OpenDeviceHubEngine/Adapter/SimulatorAdapter.swift @@ -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 diff --git a/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h b/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h index b6604bb..8d9bed4 100644 --- a/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h +++ b/engine/Sources/OpenDeviceHubPrivate/include/OpenDeviceHubPrivate.h @@ -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 diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceScreenView.swift b/engine/Sources/OpenDeviceHubViewer/DeviceScreenView.swift index e5264db..daada0b 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceScreenView.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceScreenView.swift @@ -138,8 +138,12 @@ 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 } @@ -147,7 +151,7 @@ public final class DeviceScreenView: MTKView, NSDraggingSource { } 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 } diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 8bed863..c56acec 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -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 diff --git a/engine/Sources/OpenDeviceHubViewer/KeyboardLanguage.swift b/engine/Sources/OpenDeviceHubViewer/KeyboardLanguage.swift new file mode 100644 index 0000000..819affe --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/KeyboardLanguage.swift @@ -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.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 + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 77368c0..659c2a5 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -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 @@ -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, @@ -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 @@ -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 @@ -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 @@ -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 } diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift index 49d7689..da67c89 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift @@ -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