Injecting Apple Pencil Pressure into macOS via CGEvent
Injecting Apple Pencil Pressure into macOS via CGEvent
One of Astropad's killer features is Apple Pencil support with full pressure and tilt sensitivity. When you draw on your iPad, the Mac app receiving the input sees a pressure-sensitive tablet event — as if you had a Wacom tablet connected.
This post covers how to capture Apple Pencil input on iOS, transmit it over the network, and inject it into macOS as a native tablet event using CGEvent.
The Pipeline
iPad (Apple Pencil)
→ UITouch events (pressure, altitude, azimuth)
→ Serialize as InputEvent
→ Send over QUIC reliable stream
→ macOS host receives InputEvent
→ Create CGEvent with tablet properties
→ Post to system event tap
→ Photoshop/Procreate sees a tablet event
iOS: Capturing Apple Pencil Input
class PencilInputView: UIView {
var onPencilInput: ((PencilEvent) -> Void)?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouches(touches, phase: .began)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouches(touches, phase: .moved)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
handleTouches(touches, phase: .ended)
}
private func handleTouches(_ touches: Set<UITouch>, phase: TouchPhase) {
guard let touch = touches.first else { return }
// Check if this is an Apple Pencil touch
guard touch.type == .pencil else { return }
let location = touch.location(in: self)
// Normalize coordinates to 0.0-1.0
let normalizedX = Float(location.x / bounds.width)
let normalizedY = Float(location.y / bounds.height)
// Pressure: 0.0 (no pressure) to 1.0 (max pressure)
let pressure = Float(touch.force / touch.maximumPossibleForce)
// Tilt: altitude angle (0 = parallel to screen, π/2 = perpendicular)
let altitudeAngle = Float(touch.altitudeAngle)
// Azimuth: rotation angle around the perpendicular axis
let azimuthAngle = Float(touch.azimuthAngle(in: self))
let event = PencilEvent(
x: normalizedX,
y: normalizedY,
pressure: pressure,
altitudeAngle: altitudeAngle,
azimuthAngle: azimuthAngle,
phase: phase,
timestamp: touch.timestamp
)
onPencilInput?(event)
}
}
Apple Pencil Pro: Roll Angle
On Apple Pencil Pro (iPad Pro M4+), you also get barrel roll:
if #available(iOS 17.5, *) {
let rollAngle = Float(touch.rollAngle) // Rotation around pencil's long axis
event.rollAngle = rollAngle
}
High-Frequency Coalesced Touches
Apple Pencil samples at 240Hz, but touchesMoved fires at screen refresh rate (120Hz). To get the full 240Hz data:
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
// Get all coalesced touches since last callback (up to 240Hz)
let coalescedTouches = event?.coalescedTouches(for: touch) ?? [touch]
for coalescedTouch in coalescedTouches {
handleTouch(coalescedTouch)
}
}
Wire Format
struct PencilEvent: Codable {
let x: Float // 0.0-1.0 normalized
let y: Float // 0.0-1.0 normalized
let pressure: Float // 0.0-1.0
let altitudeAngle: Float // radians
let azimuthAngle: Float // radians
let rollAngle: Float? // radians (Pencil Pro only)
let phase: UInt8 // 0=began, 1=moved, 2=ended
let timestamp: Double
}
Serialize with a compact binary format (not JSON) for minimal latency:
[4B] x | [4B] y | [4B] pressure | [4B] altitude | [4B] azimuth | [1B] phase = 21 bytes per event
At 240Hz, that is 5KB/s — negligible bandwidth. Send over the QUIC reliable stream.
macOS: Injecting Tablet Events via CGEvent
This is the key part. macOS has a hidden tablet event system in CGEvent that most developers don't know about.
Mouse Events with Tablet Pressure
func injectPencilEvent(_ event: PencilEvent, displayWidth: Int, displayHeight: Int) {
// Map normalized coordinates to display pixels
let x = CGFloat(event.x) * CGFloat(displayWidth)
let y = CGFloat(event.y) * CGFloat(displayHeight)
let point = CGPoint(x: x, y: y)
// Determine event type based on phase
let eventType: CGEventType
switch event.phase {
case 0: eventType = .leftMouseDown
case 1: eventType = .leftMouseDragged
case 2: eventType = .leftMouseUp
default: eventType = .mouseMoved
}
// Create the mouse event
guard let cgEvent = CGEvent(
mouseEventSource: nil,
mouseType: eventType,
mouseCursorPosition: point,
mouseButton: .left
) else { return }
// SET TABLET SUBTYPE — This is the magic
cgEvent.setIntegerValueField(
.mouseEventSubtype,
value: Int64(CGEventMouseSubtype.tabletPoint.rawValue)
)
// SET PRESSURE (0.0 to 1.0)
cgEvent.setDoubleValueField(
.tabletEventPointPressure,
value: Double(event.pressure)
)
// SET TILT
// Tablet tilt X: derived from azimuth + altitude
let tiltX = cos(Double(event.azimuthAngle)) * cos(Double(event.altitudeAngle))
let tiltY = sin(Double(event.azimuthAngle)) * cos(Double(event.altitudeAngle))
cgEvent.setDoubleValueField(.tabletEventTiltX, value: tiltX)
cgEvent.setDoubleValueField(.tabletEventTiltY, value: tiltY)
// SET TABLET BUTTON (pen tip = button 1)
if event.phase == 0 || event.phase == 1 {
cgEvent.setIntegerValueField(.tabletEventPointButtons, value: 1)
}
// Post the event
cgEvent.post(tap: .cghidEventTap)
}
The Key CGEvent Fields
| Field | Type | Range | Purpose |
|-------|------|-------|---------|
| .mouseEventSubtype | Int64 | .tabletPoint | Tells apps this is a tablet event |
| .tabletEventPointPressure | Double | 0.0 - 1.0 | Pen pressure |
| .tabletEventTiltX | Double | -1.0 - 1.0 | Horizontal tilt |
| .tabletEventTiltY | Double | -1.0 - 1.0 | Vertical tilt |
| .tabletEventPointButtons | Int64 | bitmask | Which pen buttons are pressed |
| .tabletEventRotation | Double | 0.0 - 360.0 | Barrel rotation |
| .tabletEventTangentialPressure | Double | -1.0 - 1.0 | Tangential pressure (airbrush) |
Proximity Events
When the pen approaches or leaves the tablet surface:
func injectProximityEvent(entering: Bool) {
guard let event = CGEvent(source: nil) else { return }
event.type = entering ? .tabletProximity : .tabletProximity
event.setIntegerValueField(.tabletProximityEventEnterProximity, value: entering ? 1 : 0)
event.setIntegerValueField(.tabletProximityEventPointerType, value: 1) // Pen
event.post(tap: .cghidEventTap)
}
Required Permissions
CGEvent posting requires Accessibility permission:
func checkAccessibilityPermission() -> Bool {
let trusted = AXIsProcessTrusted()
if !trusted {
// Prompt user to grant permission
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
AXIsProcessTrustedWithOptions(options)
}
return trusted
}
App Store Considerations
Important: If you plan to distribute via the Mac App Store, CGEvent posting with .cghidEventTap can trigger App Store rejection under guideline 2.4.5. Reddit user reports confirm this:
"Got rejected twice for my keyboard layout switcher app. I use CGEventTap (.listenOnly) to monitor keystrokes and CGEvent.post() to inject the corrected text."
For App Store distribution, you may need to use Accessibility APIs (AXUIElement) instead. For direct distribution (DMG, Homebrew), CGEvent works fine.
What Astropad Does
From our binary analysis:
InputOSInjector.swift— The Swift class that handles CGEvent injectionSystemKeyEventTap.swift— UsesCGEvent.tapCreatefor global key interceptionliquid_input::os::mac::cursor_monitor— Rust module tracking cursor positionmultiplayer_input_protocol— Wire format withcurrent_cursorandcursor_infomessages- The app requires Accessibility permission during onboarding (
AccessibilityPermission.swift,PermissionHelper.swift)
Part 9 of the "Building a Remote Desktop from Scratch" series. Based on reverse engineering analysis of Astropad Workbench 1.1.0.