Investigation of how Xcode 26.2 SwiftUI Previews works internally, conducted to build a standalone CLI and MCP server (PreviewsMCP).
- Tools Used
- Architecture Overview
- Two Eras of Previews
- macOS Preview Protocol
- iOS Simulator Preview Protocol
- Build Artifacts
- __designTime* Functions
- Update System
- XCPreviewAgent Startup Sequence
- Interactivity
- Commands Reference
- External References
# Dump symbols from Xcode frameworks
nm -gU <binary> | xcrun swift-demangle > symbols.txt
# Extract strings (compiler flags, env vars, XPC service names)
strings <binary> | grep -i "pattern" | sort -u
# Inspect Mach-O load commands and linked libraries
otool -L <binary>
otool -l <binary>
# Export symbols from dyld shared cache (system frameworks)
dyld_info -exports /System/Library/Frameworks/SwiftUI.framework/SwiftUI | xcrun swift-demangle
# Check entitlements
codesign -d -vvv <binary># Find preview processes
ps aux | grep XCPreviewAgent
# Unified logging (captures PreviewsMessagingOS messages)
log stream --predicate 'process == "XCPreviewAgent"' --level debug
# File descriptor inspection
lsof -p <pid>
# File system monitoring
sudo fs_usage -f filesystem -w Xcode XCPreviewAgent swift-frontend ld# Trace all writes from Xcode to the preview agent pipe
sudo dtrace -n '
syscall::write:entry /pid == <XCODE_PID>/ {
self->fd = arg0;
self->buf = arg1;
self->len = arg2;
}
syscall::write:return /self->buf && self->len > 100/ {
printf("\n=== WRITE fd=%d len=%d ===\n", self->fd, self->len);
tracemem(copyin(self->buf, self->len < 4096 ? self->len : 4096), 4096);
self->buf = 0;
}
' 2>&1 | tee trace.txt
# Trace pipe reads on the agent side
sudo dtrace -n '
syscall::read:entry /execname == "XCPreviewAgent"/ {
self->fd = arg0;
self->buf = arg1;
self->len = arg2;
}
syscall::read:return /self->buf && arg1 > 0/ {
printf("\n=== READ fd=%d len=%d ===\n", self->fd, arg1);
tracemem(copyin(self->buf, arg1 < 2048 ? arg1 : 2048), 2048);
self->buf = 0;
}
' 2>&1 | tee agent-reads.txt# Find preview-generated artifacts
find ~/Library/Developer/Xcode/DerivedData -name "*thunk*" -o -name "*XCPREVIEW*"
# Inspect thunk source (the __designTime* substituted version)
cat <DerivedData>/.../ContentView.1.preview-thunk.swift
# VFS overlay files
cat <DerivedData>/.../vfsoverlay-ContentView.1.preview-thunk.swift.jsonHost-side SharedFrameworks (12) — inside Xcode.app/Contents/SharedFrameworks/:
| Framework | Symbols | Purpose |
|---|---|---|
| PreviewsPipeline | 6,720 | Main orchestrator, 15-step pipeline |
| PreviewsModel | 5,884 | Data model |
| PreviewsFoundationHost | 4,336 | Shared utilities, PropertyList handling |
| PreviewsMessagingHost | 3,042 | Host-side IPC coordinator |
| PreviewsUI | 1,294 | Rendering in Xcode canvas |
| PreviewsDeveloperTools | 830 | Developer-facing tools |
| PreviewsScenes | 684 | Scene management |
| PreviewsSyntax | 368 | Source code parsing (SwiftSyntax) |
| PreviewsXcodeUI | 297 | Xcode-specific UI bindings |
| PreviewsPlatforms | 507 | Platform-specific build logic |
| PreviewsXROSMessaging | 232 | visionOS messaging |
| PreviewsXROSServices | 271 | visionOS services |
Device-side PrivateFrameworks (9) — TBD stubs in SDK:
PreviewsInjection, PreviewsServices, PreviewShellKit, PreviewsMessagingOS, PreviewsFoundationOS, PreviewsOSSupport, PreviewsOSSupportUI, PreviewsServicesUI, PreviewsUIKitMacHelper
Key Binaries:
libPreviewsMacros.dylib—#Preview/@Previewablemacro plugin (SwiftSyntax-based)XCPreviewAgent.app— per-platform preview agent (built from UITestingAgent source)libPreviewsJITStubExecutor.a— JIT stub execution
XCPreviewAgent bundle IDs:
previews.com.apple.PreviewAgent.macOS— macOS nativepreviews.com.apple.PreviewAgent.iOS— iPhone Simulatorpreviews.com.apple.PreviewAgent.Catalyst— iPad/Catalyst-
- watchOS, tvOS, visionOS variants
Discovered via dyld_info -exports:
SwiftUI._PreviewHost — ObjC-bridgeable class (has metaclass):
static makeHost(content: A) -> _PreviewHoststatic makeHost(providerType: Any.Type) -> _PreviewHost?.previews -> [SwiftUI._Preview]
SwiftUI._Preview — struct with direct view access:
.content -> SwiftUI.AnyView← the actual rendered view.id -> Int.displayName -> String?.layout -> PreviewLayout.device -> PreviewDevice?.colorScheme -> ColorScheme?.interfaceOrientation -> InterfaceOrientation
@_dynamicReplacement(for:)was the primary hot-swap mechanism- Thunks compiled into separate standalone
.dylibfiles - Stored in
Intermediates.noindex/Previews(separate from normal build) -enable-private-importsand-enable-implicit-dynamiccompiler flags@_private(sourceFile:)imports for accessing private types- XPC via
UVKit/UVIntegrationXcode plugins
- JIT Executor replaced
@_dynamicReplacementas the default mode ENABLE_DEBUG_DYLIB— app compiled as.debug.dylib+ trampoline executable- Build artifacts shared between Build-and-Run and Preview
- Three rebuild levels: small (literal), middle (structural), large (ABI-breaking)
@_dynamicReplacementstill exists as fallback (Falling back to Dynamic Replacement: false)
Confirmed from Xcode diagnostics:
runMode = JIT Executor
Falling back to Dynamic Replacement: false
JIT Mode User Enabled: true
Unix domain socket (not XPC, not TCP). Observed via lsof:
XCPreview fd=3 unix ->0x668488a6196ae3b9
Xcode writes to one end (fd=66 on Xcode side), agent reads from the other (fd=3).
Captured via dtrace on Xcode's write() syscall with SIP disabled. Messages are raw data on the Unix socket — no framing protocol or length prefix. (An initial review hypothesized 8-byte framing headers, but these were traced to the agent reading its own PkgInfo/saved state files on fd=8, not the pipe on fd=3.)
The fd numbers change per Xcode launch (observed fd=29, 64, 66 on the Xcode side; always fd=3 on the agent side).
| Order | Size | Format | Content |
|---|---|---|---|
| 1 | ~357 B | XML plist | Workspace path + timestamp (sent twice) |
| 2 | ~19 KB | Binary plist | NSKeyedArchiver-encoded workspace state (editor documents, scheme, run destination) |
| 3 | ~470 B | JSON | VFS overlay (thunk → source mapping) |
| 4 | ~646 B | Raw Swift | Thunk source with __designTime* substitutions |
| 5 | ~40 KB | Binary plist | NSKeyedArchiver-encoded workspace state (larger, includes build settings) |
Note: Earlier analysis misidentified some binary plist messages as "compiled
.ofiles." The large binary messages (18-40KB) starting withbplist00areNSKeyedArchiverarchives containingIDEWorkspaceDocumentstate — not compiled object files. The actual compiled objects appear to be sent via the JIT executor's internal mechanism (XOJITExecutor), not as raw pipe messages. On iOS, they're written to theOOPJit/filesystem directory instead.
Message 1: Workspace Info
<dict>
<key>LastAccessedDate</key>
<date>2026-03-17T03:04:00Z</date>
<key>WorkspacePath</key>
<string>/Users/.../MultiModuleTest</string>
</dict>Message 2: VFS Overlay
{
"case-sensitive": "false",
"roots": [{
"contents": [{
"external-contents": ".../FeatureView.1.preview-thunk.swift",
"name": "FeatureView.swift",
"type": "file"
}],
"name": ".../Sources/FeatureModule",
"type": "directory"
}],
"version": 0
}Message 3: Thunk Source Code
import func SwiftUI.__designTimeFloat
import func SwiftUI.__designTimeString
import func SwiftUI.__designTimeInteger
import func SwiftUI.__designTimeBoolean
#sourceLocation(file: ".../FeatureView.swift", line: 1)
import SwiftUI
import ViewLibrary
public struct FeatureView: View {
public init() {}
public var body: some View {
VStack {
Text(__designTimeString("#1192_0", fallback: "Feature Screen"))
.font(.title)
CounterView()
}
}
}
#Preview {
FeatureView()
}Message 4: Compiled Object Files
Binary .o data sent in chunks (4KB-18KB) for in-memory JIT linking by the XOJITExecutor.
Captured via log stream (no SIP needed). Binary plist format.
MacOSSnapshotPayload — sent every ~500ms:
MacOSSnapshotPayload(
renderPayload: RenderPayload(
bitmapDescription: BitmapDescription(
data: 412416 bytes,
width: 288,
height: 358,
bytesPerRow: 1152,
bitmapInfo: 8194,
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: "kCGColorSpaceSRGB"
),
selectableRegions: [],
snapshotCornerRadius: 16.0,
scaleFactor: 2.0
)
)
Wire format (PropertyList wrapped):
Message<OneWayContent>(
content: PipeServiceInterface<HostAgentSystem.HostEndpoint>
.OneWayContent.streamMessage(
message: ["payload": ["renderPayload": [...]]],
destination: <stream-UUID>
)
)
No event proxying over the pipe. The XCPreviewAgent has its own NSWindow and receives events directly from the macOS window server. Confirmed by dtrace: zero writes from Xcode during button clicks in the preview canvas.
| Process | Location | Role |
|---|---|---|
previewsd |
Inside simulator runtime | Preview daemon, routes messages |
PreviewShell.app |
Inside simulator runtime | Hosts rendered view in UIKit scene |
XCPreviewAgent.app |
Installed in Previews simulator device set | Does JIT linking, scrapes PreviewRegistry |
PreviewShellMac |
macOS host | macOS-side bridge |
XPC services via previewsd daemon — NOT a Unix socket pipe like macOS.
Additionally, compiled objects are written to the filesystem instead of sent over a pipe:
.../tmp/OOPJit/previews/<session-id>/cf.<random> (16KB each, transient)
Two distinct XPC services connect PreviewShell to XCPreviewAgent:
- "Agent nonUI preview service" — for preflight updates,
cancelUpdate, non-rendering operations - "Agent scene preview service" — for instance updates involving the rendering scene
Two pipe service systems:
HostShellSystem(HostEndpoint/ShellEndpoint) — pipe between Xcode host andPreviewShellHostAgentSystem(HostEndpoint) — pipe between host andXCPreviewAgent(throughPreviewShell)
Updates go through a two-phase protocol:
Xcode
→ previewsd: <ServiceMessage N: cancelUpdate> (cancel previous)
→ previewsd: <ServiceMessage N+1: update>
→ PreviewShell: receives update via "Daemon preview service" (two-way)
- Runs PreviewsJITLinker:
- UpdateTargetDescriptions (5ms)
- ApplyPendingUpdates (3ms)
- RunNewInitializers (0.5ms)
- Phase 1 (preflight): entryPointCategory = "uv.previewPreflight"
→ XCPreviewAgent via "Agent nonUI preview service"
- Phase 2 (instance): entryPointCategory = "uv.previewInstance"
→ XCPreviewAgent via "Agent scene preview service": performUpdate(...)
- Scrapes runtime for PreviewRegistry types
- Finds matching registry by source file + line
- Sends .updateHandshake(prefs: [portrait], seed: N) via FrontBoard scene action
← PreviewShell: receives handshake, replies "Success"
← Xcode: receives rendered frame
The system also supports kill/relaunch cycles (<ServiceMessage: kill> → <ServiceMessage: relaunch>) for crash recovery, which triggers a full __previews_injection_perform_first_jit_link and __previews_injection_run_user_entrypoint again.
["previewPayload": [
"previewSpecification": [
"location": [
"line": 23,
"file": [
"fileName": "FeatureView.swift",
"moduleName": "FeatureModule"
]
],
"discriminant": "registryPreview",
"stableID": [
"registryType": "preview",
"sourceFilePath": ".../FeatureView.swift",
"registryIndexInFile": 1
]
],
"renderEffects": []
]]
The agent scrapes the runtime for all PreviewRegistry conformances:
Found preview registry: ViewLibrary/Previews.swift:3
Found preview registry: FeatureModule/FeatureView.swift:23
It matches by sourceFilePath + registryIndexInFile from the stableID.
Fully proxied through Xcode's canvas. The simulator runs headless (no Simulator.app window). User clicks in Xcode's canvas are translated to iOS touch events and sent via FrontBoard scene actions (UVPreviewSceneAction) to PreviewShell, which dispatches them to XCPreviewAgent.
For standalone tools (without Xcode's canvas), interactivity could be achieved via the Facebook IDB approach: using Apple's private frameworks (SimulatorHID.framework, IndigoHID, or XCTest accessibility APIs) to synthesize touch events directly in the simulator. IDB (idb_companion) uses FBSimulatorControl which wraps CoreSimulator.framework and SimulatorKit to send HID events without going through FrontBoard.
PreviewShell.app renders the SwiftUI view inside a SimDisplayScene. The rendered output is sent back to Xcode via previewsd → PreviewShellMac (macOS bridge) for compositing in the preview canvas.
Xcode (macOS host)
→ host previewsd (XPC: com.apple.previewsd, macOS)
→ PreviewShellMac (XPC: com.apple.previewshellmacapp, macOS)
→ sim previewsd (XPC: com.apple.previewsd, inside simulator)
→ PreviewShell (UIKit app: com.apple.PreviewShell, in simulator)
→ XCPreviewAgent (UIKit app: previews.com.apple.PreviewAgent.iOS, in simulator)
The iOS XCPreviewAgent is NOT launched directly by Xcode. It's launched by the simulator's launchd_sim as a UIKit application. Xcode installs the agent app into the Previews simulator device set first.
Key environment variables set on the agent:
XCODE_RUNNING_FOR_PREVIEWS=1
DYLD_LIBRARY_PATH=<DerivedData>/Build/Products/Debug-iphonesimulator
PACKAGE_RESOURCE_BUNDLE_PATH=<DerivedData>/Build/Products/Debug-iphonesimulator
SIMULATOR_UDID=<previews-device-uuid>
SIMULATOR_DEVICE_NAME=iPhone
SIMULATOR_MODEL_IDENTIFIER=iPhone18,1
SIMULATOR_RUNTIME_VERSION=26.2
SIMULATOR_ARCHS=arm64
SIMULATOR_MAINSCREEN_SCALE=3.000000
SIMULATOR_MAINSCREEN_WIDTH=1206
SIMULATOR_MAINSCREEN_HEIGHT=2622Simulator launchd services:
UIKitApplication:com.apple.PreviewShell[1c62][rb-legacy] → PID 7367
com.apple.previewsd → PID 7347
UIKitApplication:previews.com.apple.PreviewAgent.iOS[fa5b] → PID 8244
The cf.* files in the OOPJit/previews/<session>/ directories are raw ARM64 machine code pages, NOT Mach-O object files. They are mapped directly into the process address space by the XOJIT executor.
.../tmp/OOPJit/previews/<session-id>/
├── cf.8rmugb 16,384 B # ARM64 code page (starts with sub sp, sp, ...)
├── cf.Bnec9q 16,384 B # ARM64 code page
├── cf.PFeUA7 114,688 B # Large code page (main module)
└── cf.QiD0f6 16,384 B # ARM64 code page (starts with ret)
Magic bytes observed:
ff c3 01 d1/ff 43 02 d1/ff 83 00 d1— ARM64sub sp, sp, #N(function prologues)c0 03 5f d6— ARM64ret(function returns)5f 5f 5f—___symbol name segment prefixes
These exist on BOTH the macOS host (/tmp/OOPJit/...) and the simulator side (.../Simulator Devices/<uuid>/.../tmp/OOPJit/...), suggesting the host compiles and writes them, and the simulator reads them via shared filesystem access.
| Aspect | macOS | iOS Simulator |
|---|---|---|
| Transport | Unix domain socket (fd=3) | XPC via previewsd daemon |
| Processes | Xcode → XCPreviewAgent | Xcode → previewsd → PreviewShell → XCPreviewAgent |
| JIT objects | Via XOJITExecutor internal mechanism |
Raw ARM64 code pages written to OOPJit/ filesystem on both host and simulator (shared filesystem) |
| Rendering | Agent has real NSWindow | UIKit scene (SimDisplayScene), FrontBoard actions |
| User events | Direct via window server (no proxying) | Proxied via FrontBoard scene actions (UVPreviewSceneAction) |
| Preview payload | ["previewSpecification": ...] |
Same format |
| Update protocol | Raw data on socket (no framing) | ServiceMessage numbered messages, two-phase (preflight + instance) |
| XPC services | N/A (direct socket) | "Agent nonUI preview service" + "Agent scene preview service" |
| Pipe systems | HostAgentSystem only |
HostShellSystem + HostAgentSystem |
| Crash recovery | Agent restart | kill/relaunch cycle via ServiceMessage |
PreviewTestApp.app/
├── Contents/MacOS/
│ ├── PreviewTestApp # Trampoline executable (small)
│ ├── PreviewTestApp.debug.dylib # Actual code (install name: @rpath/...)
│ └── __preview.dylib # Preview stub (no exported symbols)
Build/Products/Debug/
├── ViewLibrary.o # Merged static lib
├── FeatureModule.o # Merged static lib
├── ViewLibrary.swiftmodule/ # Module interface
└── FeatureModule.swiftmodule/
No .dylib files — everything is statically linked. JIT linker merges in-memory.
Build/Intermediates.noindex/.../Objects-normal/arm64/
├── ContentView.1.preview-thunk.swift # __designTime* substituted source
├── ContentView.1.preview-thunk.o # Compiled thunk
├── ContentView.1.preview-thunk-launch.o # Launch object (PreviewRegistry)
├── ContentView.1.preview-thunk.dia # Diagnostics
├── vfsoverlay-ContentView.1.preview-thunk.swift.json
└── vfsoverlay-ContentView.__XCPREVIEW_THUNKSUFFIX__.preview-thunk.swift.json
# Previews uses a SEPARATE simulator device set
xcrun simctl --set ~/Library/Developer/Xcode/UserData/Previews/Simulator\ Devices list
# ~1.6GB dedicated device at:
~/Library/Developer/Xcode/UserData/Previews/Simulator Devices/<UUID>/SwiftUI exports these for live literal editing:
import func SwiftUI.__designTimeString // (_ id: String, fallback: String) -> String
import func SwiftUI.__designTimeInteger // (_ id: String, fallback: Int) -> Int
import func SwiftUI.__designTimeFloat // (_ id: String, fallback: Double) -> Double
import func SwiftUI.__designTimeBoolean // (_ id: String, fallback: Bool) -> Bool
import func SwiftUI.__designTimeSelection // (...) — for enum/choice selectionsExample transformation (from captured DerivedData thunk):
// Original:
VStack(spacing: 20) {
Button("Increment") { count += 1 }
// Thunk:
VStack(spacing: __designTimeInteger("#8919_0", fallback: 20)) {
Button(__designTimeString("#8919_1", fallback: "Increment")) {
count += __designTimeInteger("#8919_2", fallback: 1)
}
IDs like "#8919_0" are keys into a runtime value store (__designTimeValues dictionary). Xcode can update values without recompilation.
Eligibility rules (what gets replaced):
- Literals inside code blocks / closures / computed property bodies
- NOT stored property initializers (
@State var count = 0— the0stays) - NOT macro arguments (
#Preview("Name")— the"Name"stays) - NOT strings with interpolation (
"Count: \(count)"— skipped entirely) - NOT import statements, attributes, enum raw values, switch case patterns
| Tier | Edit Type | What Happens | @State |
|---|---|---|---|
| Small | Literal only ("Hello" → "World") |
__designTime* value store update, no recompile |
Preserved |
| Middle | Structural (add/remove views) | Thunk .o recompiled, dylib NOT rebuilt |
Resets |
| Large | ABI-breaking (add @State property) |
Full rebuild of all .o + dylib |
Resets |
Confirmed behavior (tested on both single-module and multi-module projects):
@_dynamicReplacementwas never triggered in any scenario on Xcode 26.2- All tiers use VFS overlay +
__designTime*+ JIT Executor
WorkCollectionStep → WorkspaceBuildStep → BuiltTargetDescriptionsStep →
BuiltProductContextStep → LaunchConfigurationStep → RunDestinationStep →
CodeCompilationStep (CompileBuildStep → LinkBuildStep → EmitModuleBuildStep) →
DynamicLibraryBuildStep → CodeSignBuildStep →
LaunchThunksStep (ThunkProductsStep → VerifyThunkPresenceBuildStep) →
AgentAssignmentStep → ExecutionPointUpdateStep → PerformUpdateStep
Captured via log stream (no SIP needed):
1. Agent launched with __PREVIEWS_JIT_LINK env var
2. Looking for Previews JIT link entry point → Found
3. __previews_injection_jit_link_entrypoint start
4. Received request to connect JIT → Received JIT executor
5. XOJITExecutor: __xojit_executor_run_program
PreviewsInjection __previews_injection_perform_first_jit_link
6. JITLinkWaiter: Performing initial JIT link (completed in 16ms)
7. __xojit_executor_run_program
PreviewsInjection __previews_injection_run_user_entrypoint
8. __debug_blank_executor_run_user_entry_point called
9. ControlAgent: host pipe connection succeeded
10. streamOpened → destination: <UUID-1>
streamOpened → destination: <UUID-2>
11. EntryPointIndex received host message stream for:
- Ultraviolet.NSPreviewSuppressedPresentations
- Ultraviolet.MacOSSnapshots
12. MacOSSnapshotPayload sent every ~500ms
- Check
__PREVIEWS_JIT_LINK→__previews_injection_jit_link_entrypoint - Check
__PREVIEWS_INTERPOSED_DEBUG_DLIB(pseudodylib) - Look up debug dylib by install name (
__PREVIEWS_EXECUTABLE_DYLIB_NAME) - Look up debug dylib by relative path
- Find
__debug_main_executable_dylib_entry_pointin dylib - Find provided entry point name
- Fall back to
main
__PREVIEWS_JIT_LINK — trigger JIT link entry point
__PREVIEWS_EXECUTABLE_DYLIB_NAME — dylib install name
__PREVIEWS_INTERPOSED_DEBUG_DLIB — debug dylib interposition
__PREVIEWS_AGENT_STUB_EXECUTOR_STDERR_REDIRECT — stderr redirect
__PREVIEWS_AGENT_SKIP_USER_ENTRY_POINT — skip user entry
__XCPREVIEW_THUNKSUFFIX__ — thunk dylib naming suffix
The XCPreviewAgent creates a real NSWindow. Events (clicks, scrolls, keyboard) are delivered directly by the macOS window server — Xcode does not proxy them. Confirmed via dtrace: zero writes from Xcode to the agent pipe during user interaction.
All events are proxied through Xcode's canvas:
- User clicks in Xcode's preview canvas
- Xcode translates to iOS touch coordinates
- Sends via FrontBoard scene actions (
UVPreviewSceneAction) toPreviewShell PreviewShelldispatches toXCPreviewAgent- SwiftUI processes the event →
@Stateupdates → re-renders - Rendered frame sent back to Xcode for display
The simulator runs headless — no Simulator.app window appears for previews.
Replicate Xcode's 6-process chain: connect to com.apple.previewsd via NSXPCConnection, send ServiceMessage updates with the previewSpecification payload. Receive rendered frames back.
Pros: Pixel-perfect rendering, full trait support, exact Xcode behavior.
Cons: Extremely complex. Requires understanding the full XPC protocol, NSKeyedArchiver workspace state, XOJIT code page format. May require Xcode-specific entitlements.
Compile our own iOS host app (like the macOS spike: dlopen + UIHostingController + UIWindow), install via simctl install, launch via simctl launch. Use simctl io screenshot for snapshots.
Pros: Reuses proven dlopen + hosting approach. No XPC protocol needed. Simpler.
Cons: Doesn't use Apple's preview infrastructure. May miss device-specific traits. Need to handle compilation for iOS simulator target (arm64-apple-ios-simulator).
Use Approach B for hosting, but add interactivity via the Facebook IDB approach: use Apple's private frameworks to synthesize touch events in the simulator.
Relevant frameworks:
SimulatorHID.framework— HID event injection (pointed to bySIMULATOR_HID_SYSTEM_MANAGERenv var)IndigoHID— lower-level HID synthesisCoreSimulator.framework— simulator management APIsXCTestaccessibility APIs —XCUIElement.tap(),XCUIElement.swipeUp()
IDB's approach (idb_companion / FBSimulatorControl):
- Uses
CoreSimulator.frameworkto get aSimDevicehandle - Calls
SimDevice.sendKeyboardEvent/SimDevice.sendTouchEventfor HID input - Uses
SimDevice.io.screenshotfor captures - All from a macOS process — no app installation needed for events
For PreviewsMCP, the flow would be:
previews-mcp (macOS host)
├─ Compile view for iOS simulator target (arm64-apple-ios-simulator)
├─ Boot simulator: xcrun simctl --set <previews-device-set> boot <device>
├─ Install agent app: xcrun simctl install <device> PreviewsMCPAgent.app
├─ Launch: xcrun simctl launch <device> com.previewsmcp.agent
├─ Agent app: dlopen(preview.dylib) → UIHostingController → UIWindow
├─ Snapshot: xcrun simctl io <device> screenshot
├─ Interact: CoreSimulator.framework → SimDevice.sendTouchEvent(x, y)
└─ Hot-reload: file watcher → recompile → signal host app to reload
Recommended: Approach C using CoreSimulator.framework directly — no xcrun simctl subprocess spawning. The framework provides typed Swift/ObjC APIs for everything simctl does:
simctl command |
CoreSimulator.framework equivalent |
|---|---|
simctl list |
SimDeviceSet.defaultSet.devices |
simctl boot |
SimDevice.boot() |
simctl install |
SimDevice.installApplication(at:) |
simctl launch |
SimDevice.launchApplication(bundleID:) |
simctl io screenshot |
SimDevice.io.screenshot() |
simctl shutdown |
SimDevice.shutdown() |
| N/A (no simctl equivalent) | SimDevice.sendTouchEvent(x:y:) |
CoreSimulator.framework is a private framework located at /Library/Developer/PrivateFrameworks/CoreSimulator.framework. Facebook's idb_companion uses it without Xcode entitlements. This gives us typed APIs, no subprocess overhead, and access to touch event injection that simctl doesn't expose.
References for CoreSimulator.framework usage:
- Facebook IDB / FBSimulatorControl — wraps CoreSimulator for simulator management, HID event injection, screenshots
- mdump — reference implementation using CoreSimulator.framework
Next steps to investigate:
- Link against
CoreSimulator.frameworkand verify we can createSimDeviceSet/SimDeviceinstances - Test
SimDevice.sendTouchEventfor HID input synthesis - Determine if the Previews simulator device set can be accessed via
SimDeviceSet(setPath:) - Check if
SimDevice.io.screenshot()returns content-only (no chrome) or the full simulated display
# Host-side frameworks
for name in PreviewsPipeline PreviewsSyntax PreviewsModel PreviewsMessagingHost \
PreviewsFoundationHost PreviewsDeveloperTools PreviewsScenes PreviewsUI \
PreviewsXcodeUI PreviewsPlatforms PreviewsXROSMessaging PreviewsXROSServices; do
nm -gU "$SF/$name.framework/Versions/A/$name" | xcrun swift-demangle > "${name}_symbols.txt"
done
# System frameworks (dyld cache)
dyld_info -exports /System/Library/Frameworks/DeveloperToolsSupport.framework/DeveloperToolsSupport \
| xcrun swift-demangle > DeveloperToolsSupport_symbols.txt
dyld_info -exports /System/Library/Frameworks/SwiftUI.framework/SwiftUI \
| xcrun swift-demangle | grep -i "preview\|hosting" > SwiftUI_preview_symbols.txt# Capture XCPreviewAgent logs during preview session
log stream --predicate 'process == "XCPreviewAgent"' --level debug 2>&1 | tee agent-log.txt
# iOS: also capture PreviewShell and previewsd
log stream --predicate 'process == "XCPreviewAgent" OR process == "PreviewShell" OR \
process == "previewsd" OR process == "PreviewShellMac"' --level debug 2>&1 | tee ios-log.txt
# File descriptor inspection
lsof -p <agent-pid> | grep -v "\.dylib\|txt\|cwd\|rtd\|KQUEUE"# Trace Xcode writes to the agent pipe
XCODE_PID=$(pgrep -x Xcode)
sudo dtrace -n '
syscall::write:entry /pid == '$XCODE_PID'/ {
self->fd = arg0; self->buf = arg1; self->len = arg2;
}
syscall::write:return /self->buf && self->len > 100/ {
printf("\n=== WRITE fd=%d len=%d ===\n", self->fd, self->len);
tracemem(copyin(self->buf, self->len < 4096 ? self->len : 4096), 4096);
self->buf = 0;
}' 2>&1 | tee xcode-pipe-trace.txt# List preview simulator devices (separate from regular simulators)
xcrun simctl --set ~/Library/Developer/Xcode/UserData/Previews/Simulator\ Devices list
# Take screenshot of preview simulator
xcrun simctl --set ~/Library/Developer/Xcode/UserData/Previews/Simulator\ Devices \
io <device-uuid> screenshot preview.pngTouch injection into the iOS simulator requires bypassing the normal macOS mouse → touch conversion that Simulator.app performs internally. Three approaches were investigated.
Result: Failed. Creating an IOHIDEventSystemClient inside the simulator app and dispatching events requires the com.apple.private.hid.client.event-dispatch entitlement, which only system processes (backboardd, SpringBoard) possess.
Result: Failed. IndigoHIDMessageForMouseNSEvent creates pointer events (eventType=3), not touch events (eventType=2). Sending these directly causes the app to crash or background. Patching eventType 3→2 with seed data is accepted but silently dropped.
Key findings from reverse engineering:
- IndigoPayload grew from 144→160 bytes since IDB was written
- IndigoTouch has 19 fields (was 18)
- Total message: 352 bytes (was 320)
- IDB's 5-arg call to
IndigoHIDMessageForMouseNSEventno longer matches the 6-arg signature sendWithMessage:is NOT called by Simulator.app when the user clicks — it's for external API only
Result: Works (headless=false only). Post CGEventCreateMouseEvent to the Simulator.app window coordinates. Simulator.app's SimDigitizerInputView layer converts the click to a touch event internally.
Result: Failed. lldb tracing with SIP disabled revealed the actual touch delivery path inside Simulator.app:
- User clicks in Simulator.app window
SimDigitizerInputViewprocesses the NSEvent- Calls
simDigitizerInputView(_:touchEvent:)onSimDeviceLegacyHIDClient - The HID client converts the touchEvent to wire format and sends via Mach IPC
The touchEvent parameter is a 160-byte Swift struct. However, calling this method from outside with a freshly-created SimDigitizerInputView doesn't work — the view needs an established HID session connection that only Simulator.app's own view hierarchy provides. The method is a pure Swift protocol witness (no ObjC selector), making external invocation fragile.
Result: Works — fully headless, no mouse movement, no SIP needed.
Based on Lyft's Hammer testing framework. Runs entirely inside the iOS host app process:
dlopenIOKit + BackBoardServices in the simulator runtimeIOHIDEventCreateDigitizerFingerEvent()— create touch event (transducerType=3/hand)IOHIDEventSetIntegerValue(event, 0xB0019, 1)— isDisplayIntegrated flagIOHIDEventSetSenderID(event, nonzero)— required sender IDBKSHIDEventSetDigitizerInfo(event, contextId, ...)— route to window viaUIWindow._contextIdUIApplication._enqueueHIDEvent(event)— deliver to app event loop
Key details:
BKSHIDEventSetDigitizerInfois in BackBoardServices.framework (available in simulator runtime)UIApplication._enqueueHIDEvent:exists and works without entitlementsUIWindow._contextIdprovides the UInt32 context ID for event routing- Event masks: began/ended =
.touch | .range(0x03), moved =.position(0x04) - Parent event mask:
.touch(0x02) - Finger radius: 5.0 (majorRadius 0xB0014, minorRadius 0xB0015)
- Swipe = touch began → interpolated touch moves → touch ended
No Simulator.app window needed. No mouse cursor movement. No SIP requirement. No entitlements needed. Verified: Count: 0 → Count: 1 on headless iOS 26.2 simulator.
- How SwiftUI Preview Works Under the Hood | onee.me — Best source on Xcode 16+ JIT Executor path
- Behind SwiftUI Previews | Guardsquare — Pre-Xcode 16
@_dynamicReplacementanalysis - Building Stable Preview Views | fatbobman —
@_private(sourceFile:)imports documentation - Dynamic libraries and code replacements in Swift | theswiftdev —
@_dynamicReplacementbackground
Investigation conducted March 2026. Xcode 26.2 (17C49), macOS 26.2, Swift 6.2.3, Apple Silicon.