Virtualization.framework Keyboard Event Pipeline

Reverse engineering findings for the keyboard event pipeline in Apple's Virtualization.framework (macOS 26.2, version 259.3.3.0.0). Documents how keyboard events flow from the macOS host to the virtual iPhone guest.


Event Flow Architecture

There are two pipelines for sending keyboard events to the VM:

Pipeline 1: _VZKeyEvent -> sendKeyEvents: (Standard Keys)

_VZKeyEvent(type, keyCode)
  -> _VZKeyboard.sendKeyEvents:(NSArray<_VZKeyEvent>)
    -> table lookup: keyCode -> intermediate index
    -> pack: uint64_t = (index << 32) | is_key_down
    -> std::vector<uint64_t>
    -> [if type==2] sendKeyboardEventsHIDReport:keyboardID: (switch -> IOHIDEvent -> HID reports)
    -> [fallback]   eventSender.sendKeyboardEvents:keyboardID: (VzCore C++ layer)

Pipeline 2: _processHIDReports (Raw HID Reports)

Raw HID report bytes
  -> std::span<const unsigned char>{data_ptr, length}
  -> std::vector<std::span<...>>{begin, end, cap}
  -> VZVirtualMachine._processHIDReports:forDevice:deviceType:
    -> XpcEncoder::encode_data(span) -> xpc_data_create
    -> XPC to VMM process

_VZKeyEvent Structure

From IDA + LLDB inspection:

struct _VZKeyEvent {  // sizeof = 0x18
    uint8_t  isa[8];      // offset 0x00 -- ObjC isa pointer
    uint16_t _keyCode;    // offset 0x08 -- Apple VK code (0x00-0xB2)
    uint8_t  _pad[6];     // offset 0x0A -- padding
    int64_t  _type;       // offset 0x10 -- 0 = keyDown, 1 = keyUp
};

Initializer: _VZKeyEvent(type: Int64, keyCode: UInt16)


_VZKeyboard Object Layout

From LLDB memory dump:

+0x00: isa
+0x08: _eventSender (weak, id<_VZHIDAdditions, _VZKeyboardEventSender>)
+0x10: _deviceIdentifier (uint32_t) -- value 1 for first keyboard
+0x18: type (int64_t) -- 0 for USB keyboard, 2 for type that tries HIDReport first

Lookup Tables in sendKeyEvents:

Two tables indexed by Apple VK keyCode (0x00-0xB2, 179 entries x 8 bytes each):

Table 1 (validity flags): All valid entries = 0x0000000100000000 (bit 32 set). Invalid entries = 0.

Table 2 (intermediate indices): Maps Apple VK codes to internal indices (0x00-0x72).

The tables are OR'd: combined = table1[vk] | table2[vk]. Bit 32 check validates the entry. The lower 32 bits of combined become the intermediate index.

Sample Table 2 Entries

Apple VKKeyTable2 (Index)HID PageHID Usage
0x00A0x0070x04
0x01S0x1270x16
0x24Return0x2470x28
0x31Space0x2970x2C
0x35Escape0x2570x29
0x38Shift0x5170xE1
0x37Command0x5370xE3

Invalid VK Codes (both tables = 0, silently dropped)

0x48 (Volume Up), 0x49 (Volume Down), 0x4A (Mute), and many others.


Packed Event Format (std::vector<uint64_t>)

Each element in the vector sent to sendKeyboardEvents:keyboardID::

bits 63:32 = intermediate_index (from table2, lower 32 bits of combined)
bits 31:1  = 0
bit  0     = is_key_down (1 = down, 0 = up)

sendKeyboardEventsHIDReport Switch Statement

For type-2 keyboards, the intermediate index is mapped to IOHIDEventCreateKeyboardEvent(page, usage) via a large switch.

Standard Keyboard Entries (HID Page 7)

IndexHID PageHID UsageMeaning
0x00-0x1974-29Letters a-z
0x1A-0x23730-39Digits 1-0
0x24740Return
0x25741Escape
0x29744Space
0x48-0x4B779-82Arrow keys
0x50-0x537224-227L-Ctrl/Shift/Alt/Cmd

Consumer / System Entries (Non-Standard Pages)

IndexHID PageHID UsageMeaning
0x6E12671Consumer Volume Down
0x6F12674Consumer Volume Up
0x7012207Consumer Play/Pause
0x7112545Consumer Snapshot
0x721155Generic Desktop Wake

Home/Menu (Consumer page 0x0C, usage 0x40) has NO intermediate index. It cannot be sent through Pipeline 1 at all.


_processHIDReports Parameter Format

From IDA decompilation of VZVirtualMachine._processHIDReports:forDevice:deviceType: at 0x2301b2310.

The void * parameter is a pointer to std::vector<std::span>:

Level 3 (outermost): std::vector (24 bytes, passed by pointer)
  +0x00: __begin_    (pointer to span array)
  +0x08: __end_      (pointer past last span)
  +0x10: __end_cap_  (capacity pointer)

Level 2: std::span (16 bytes per element in the array)
  +0x00: data_ptr    (const unsigned char *)
  +0x08: length      (size_t)

Level 1 (innermost): raw HID report bytes

The function iterates spans in the vector:

begin = *vec;           // vec->__begin_
end   = *(vec + 1);     // vec->__end_
for (span = begin; span != end; span += 16) {
    data_ptr = *(uint64_t*)span;
    length   = *(uint64_t*)(span + 8);
    encoder.encode_data(data_ptr, length);  // -> xpc_data_create
}

deviceType: 0 = keyboard, 1 = pointing device

device: device identifier (uint32_t, matches _VZKeyboard._deviceIdentifier)


Crash Analysis: Why Raw Bytes Crashed

Passing raw [0x40, 0x00] as the void* parameter:

  1. Function reads bytes as vector struct: begin = 0x0040 (first 8 bytes), end = garbage
  2. Dereferences begin as span pointer -> reads from address ~0x0040
  3. Gets garbage data_ptr (0x700420e) and garbage length (0x300000020 = 12GB)
  4. xpc_data_create(0x700420e, 0x300000020) -> EXC_BAD_ACCESS in memcpy

The three-level indirection (vector -> span -> bytes) must be constructed correctly or the framework will dereference invalid pointers.


Swift Implementation Notes

Accessing _VZKeyboard

// Get keyboards array
let arr = Dynamic(vm)._keyboards.asObject as? NSArray
let keyboard = arr?.object(at: 0) as AnyObject

// _deviceIdentifier is an ivar, not a property -- use KVC
(keyboard as? NSObject)?.value(forKey: "_deviceIdentifier") as? UInt32

Constructing std::vector<uint64_t> for sendKeyboardEvents

let data = UnsafeMutablePointer<UInt64>.allocate(capacity: 1)
data.pointee = (index << 32) | (isKeyDown ? 1 : 0)
var vec = (data, data.advanced(by: 1), data.advanced(by: 1))
withUnsafeMutablePointer(to: &vec) { vecPtr in
    Dynamic(vm).sendKeyboardEvents(UnsafeMutableRawPointer(vecPtr), keyboardID: deviceId)
}

Constructing vector<span> for _processHIDReports

let reportPtr = UnsafeMutablePointer<UInt8>.allocate(capacity: N)
// fill report bytes...

let spanPtr = UnsafeMutablePointer<Int>.allocate(capacity: 2)
spanPtr[0] = Int(bitPattern: reportPtr)  // data pointer
spanPtr[1] = N                           // length

let vecPtr = UnsafeMutablePointer<Int>.allocate(capacity: 3)
vecPtr[0] = Int(bitPattern: UnsafeRawPointer(spanPtr))                   // begin
vecPtr[1] = Int(bitPattern: UnsafeRawPointer(spanPtr).advanced(by: 16))  // end
vecPtr[2] = vecPtr[1]                                                    // cap

Dynamic(vm)._processHIDReports(UnsafeRawPointer(vecPtr), forDevice: deviceId, deviceType: 0)

Source Files

  • Class dumps: /Users/qaq/Documents/GitHub/super-tart-vphone-private/Virtualization_26.2-class-dump/
  • IDA database: dyld_shared_cache_arm64e with Virtualization.framework

Key Functions Analyzed

FunctionAddress
-[_VZKeyboard sendKeyEvents:]0x2301b2f54
-[_VZKeyboard sendKeyboardEventsHIDReport:keyboardID:]0x2301b3230
-[VZVirtualMachine(_VZHIDAdditions) _processHIDReports:forDevice:deviceType:]0x2301b2310
-[VZVirtualMachineView _sendKeyEventsToVirtualMachine:]--
-[_VZHIDEventMonitor getHIDReportsFromHIDEvent:]0x2301b2af0

What Never Reaches the Guest (2026-10-02)

Measured on macOS 27.0.1 (Virtualization 259), against the two lookup tables -[_VZKeyboard sendKeyEvents:] reads (179 entries each, at adrp+add in the method's prologue; read from the shared cache, whose slide is one per boot) and the report descriptor of VZUSBKeyboardConfiguration, found in com.apple.Virtualization.VirtualMachine by its 05 01 09 06 A1 01 prefix:

05 01 09 06 A1 01            Generic Desktop / Keyboard
05 07 19 E0 29 E7 ... 81 02  8 modifier bits, page 7 0xE0-0xE7
... 19 00 29 91 ... 95 06    6 keys, page 7 0x00-0x91
05 08 19 01 29 03 ...        3 LEDs
C0

A boot-protocol keyboard. Of the 179 virtual key codes, 115 have an index; the rest are dropped before any report is built:

Mac key (kVK_*)CodeWhy it is lostWhat vphone sends instead (vphoned)
fn / 🌐 (Function)0x3Fhas an index (0x55) but no report fieldApple vendor top-case 0xFF / 0x03, held while fn is
JIS 英数 (JIS_Eisu)0x66no indexpage 7 0x91 (LANG2)
JIS かな (JIS_Kana)0x68no indexpage 7 0x90 (LANG1)
JIS ¥ (JIS_Yen)0x5Dno indexpage 7 0x89 (International3)
JIS _ (JIS_Underscore)0x5Eno indexpage 7 0x87 (International1)
JIS keypad , (JIS_KeypadComma)0x5Fno indexpage 7 0x85 (Keypad Comma)
Context Menu0x6Eno indexpage 7 0x65 (Application)
Help / Insert0x72no indexpage 7 0x49 (Insert)
Volume Up / Down / Mute0x48 / 0x49 / 0x4Ano indexconsumer 0xE9 / 0xEA / 0xE2

VPhoneApplication.sendEvent(_:) sends these through vphoned's input.hid (VPhoneGuestKeyMap), since nothing the virtual keyboard emits can carry them. Everything else — letters, digits, punctuation, Return, Tab, Space, Delete, Forward Delete, Caps Lock, all eight modifiers, F1–F20, arrows, Home / End / Page Up / Page Down, the keypad and ISO § — goes through the virtual keyboard unchanged.

On an iPadOS 26.6.2 guest with English and Simplified Pinyin enabled, measured by injecting each usage through vphoned in Notes:

  • page 7 E0 + 2C (⌃Space) switches the input source;
  • vendor FF / 03 (🌐) switches the input source;
  • page 7 39 (Caps Lock) types capitals in Pinyin rather than switching — iPadOS's default for Chinese input.

Esc: VPhoneApplication turns Esc into the back gesture on an iPhone guest, which has no Esc key. On an iPad guest Esc goes through as Esc, where it cancels a composition, a menu or a sheet.

Host shortcuts still apply before the guest sees anything: with AppleFnUsageType = 1 (System Settings › Keyboard › "Press 🌐 key to: Change Input Source") the Mac switches its own input source on the same fn press, and a ⌃Space or ⌘Space bound to Spotlight or input-source switching on the host is the host's unless the VM view's capturesSystemKeys takes it first. While a VM window is key, VPhoneHostHotKeys pushes kHIHotKeyModeAllDisabledExceptUniversalAccess so the guest gets them; that only takes effect when the responsible app is trusted for Accessibility, and lapses when vphone-vm is deactivated or exits. The mode does not cover the input-source hot keys: ⌃Space and ⌃⌥Space stay with the Mac, and neither the mode nor SkyLight's per-connection SLSSetSymbolicHotKeyEnabledForConnection lets Space reach vphone-vm (only Control's flags change arrives). Only switching the hot key off system-wide does, which can leave the Mac without it after a crash, so it is not done. Switch the guest's input source with 🌐 or Device › Switch Guest Input Source.