Skip to content

Latest commit

 

History

History
840 lines (668 loc) · 35 KB

File metadata and controls

840 lines (668 loc) · 35 KB

Reverse Engineering Xcode SwiftUI Previews

Investigation of how Xcode 26.2 SwiftUI Previews works internally, conducted to build a standalone CLI and MCP server (PreviewsMCP).

Table of Contents


Tools Used

Binary Analysis

# 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>

Process Tracing (SIP enabled)

# 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

Process Tracing (SIP disabled)

# 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

DerivedData Archeology

# 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.json

Architecture Overview

Xcode 26.2 Framework Layout

Host-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 / @Previewable macro 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 native
  • previews.com.apple.PreviewAgent.iOS — iPhone Simulator
  • previews.com.apple.PreviewAgent.Catalyst — iPad/Catalyst
    • watchOS, tvOS, visionOS variants

SwiftUI Internal Types (from dyld shared cache)

Discovered via dyld_info -exports:

SwiftUI._PreviewHost — ObjC-bridgeable class (has metaclass):

  • static makeHost(content: A) -> _PreviewHost
  • static 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

Two Eras of Previews

Pre-Xcode 16: @_dynamicReplacement

  • @_dynamicReplacement(for:) was the primary hot-swap mechanism
  • Thunks compiled into separate standalone .dylib files
  • Stored in Intermediates.noindex/Previews (separate from normal build)
  • -enable-private-imports and -enable-implicit-dynamic compiler flags
  • @_private(sourceFile:) imports for accessing private types
  • XPC via UVKit / UVIntegration Xcode plugins

Xcode 16+: JIT Executor (current)

  • JIT Executor replaced @_dynamicReplacement as 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)
  • @_dynamicReplacement still 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

macOS Preview Protocol

Transport

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).

Incoming Messages (Xcode → Agent)

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 .o files." The large binary messages (18-40KB) starting with bplist00 are NSKeyedArchiver archives containing IDEWorkspaceDocument state — 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 the OOPJit/ 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.

Outgoing Messages (Agent → Xcode)

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>
    )
)

Interactivity (macOS)

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.


iOS Simulator Preview Protocol

Process Architecture

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

Transport

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)

XPC Service Types

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 and PreviewShell
  • HostAgentSystem (HostEndpoint) — pipe between host and XCPreviewAgent (through PreviewShell)

Message Flow

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.

Update Payload (same format as macOS)

["previewPayload": [
    "previewSpecification": [
        "location": [
            "line": 23,
            "file": [
                "fileName": "FeatureView.swift",
                "moduleName": "FeatureModule"
            ]
        ],
        "discriminant": "registryPreview",
        "stableID": [
            "registryType": "preview",
            "sourceFilePath": ".../FeatureView.swift",
            "registryIndexInFile": 1
        ]
    ],
    "renderEffects": []
]]

Preview Registry Discovery

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.

Interactivity (iOS)

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.

Rendering

PreviewShell.app renders the SwiftUI view inside a SimDisplayScene. The rendered output is sent back to Xcode via previewsdPreviewShellMac (macOS bridge) for compositing in the preview canvas.

iOS XPC Service Chain (Full)

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)

iOS Agent Launch Details

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=2622

Simulator 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

OOPJit Files (Compiled Code Pages)

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 — ARM64 sub sp, sp, #N (function prologues)
  • c0 03 5f d6 — ARM64 ret (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.


macOS vs iOS Comparison

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

Build Artifacts

Xcode Project (single module, ENABLE_DEBUG_DYLIB)

PreviewTestApp.app/
├── Contents/MacOS/
│   ├── PreviewTestApp        # Trampoline executable (small)
│   ├── PreviewTestApp.debug.dylib  # Actual code (install name: @rpath/...)
│   └── __preview.dylib       # Preview stub (no exported symbols)

SPM Package (multi-module, static linking)

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.

Thunk Files

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 Simulator Device Set

# 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>/

__designTime* Functions

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 selections

Example 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 — the 0 stays)
  • 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

Update System

Three Tiers

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):

  • @_dynamicReplacement was never triggered in any scenario on Xcode 26.2
  • All tiers use VFS overlay + __designTime* + JIT Executor

Pipeline Steps (from PreviewsPipeline symbols)

WorkCollectionStep → WorkspaceBuildStep → BuiltTargetDescriptionsStep →
BuiltProductContextStep → LaunchConfigurationStep → RunDestinationStep →
CodeCompilationStep (CompileBuildStep → LinkBuildStep → EmitModuleBuildStep) →
DynamicLibraryBuildStep → CodeSignBuildStep →
LaunchThunksStep (ThunkProductsStep → VerifyThunkPresenceBuildStep) →
AgentAssignmentStep → ExecutionPointUpdateStep → PerformUpdateStep

XCPreviewAgent Startup Sequence

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

Entry Point Resolution (7-step fallback chain)

  1. Check __PREVIEWS_JIT_LINK__previews_injection_jit_link_entrypoint
  2. Check __PREVIEWS_INTERPOSED_DEBUG_DLIB (pseudodylib)
  3. Look up debug dylib by install name (__PREVIEWS_EXECUTABLE_DYLIB_NAME)
  4. Look up debug dylib by relative path
  5. Find __debug_main_executable_dylib_entry_point in dylib
  6. Find provided entry point name
  7. Fall back to main

Key Environment Variables

__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

Interactivity

macOS

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.

iOS Simulator

All events are proxied through Xcode's canvas:

  1. User clicks in Xcode's preview canvas
  2. Xcode translates to iOS touch coordinates
  3. Sends via FrontBoard scene actions (UVPreviewSceneAction) to PreviewShell
  4. PreviewShell dispatches to XCPreviewAgent
  5. SwiftUI processes the event → @State updates → re-renders
  6. Rendered frame sent back to Xcode for display

The simulator runs headless — no Simulator.app window appears for previews.


Implementation Approaches for Standalone iOS Previews

Approach A: Replicate the Full XPC Chain

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.

Approach B: Custom iOS Host App in Simulator (Pragmatic)

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).

Approach C: Hybrid with IDB-Style Interactivity

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 by SIMULATOR_HID_SYSTEM_MANAGER env var)
  • IndigoHID — lower-level HID synthesis
  • CoreSimulator.framework — simulator management APIs
  • XCTest accessibility APIs — XCUIElement.tap(), XCUIElement.swipeUp()

IDB's approach (idb_companion / FBSimulatorControl):

  1. Uses CoreSimulator.framework to get a SimDevice handle
  2. Calls SimDevice.sendKeyboardEvent / SimDevice.sendTouchEvent for HID input
  3. Uses SimDevice.io.screenshot for captures
  4. 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:

Next steps to investigate:

  1. Link against CoreSimulator.framework and verify we can create SimDeviceSet / SimDevice instances
  2. Test SimDevice.sendTouchEvent for HID input synthesis
  3. Determine if the Previews simulator device set can be accessed via SimDeviceSet(setPath:)
  4. Check if SimDevice.io.screenshot() returns content-only (no chrome) or the full simulated display

Commands Reference

Symbol Corpus Generation

# 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

Live Tracing (SIP enabled)

# 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"

Pipe Tracing (SIP disabled)

# 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

Previews Simulator Device Set

# 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.png

iOS Simulator Touch Injection

Overview

Touch injection into the iOS simulator requires bypassing the normal macOS mouse → touch conversion that Simulator.app performs internally. Three approaches were investigated.

Approach 1: IOHIDEventSystemClientDispatchEvent (In-App)

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.

Approach 2: IndigoHID via SimDeviceLegacyHIDClient (Host-Side)

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 IndigoHIDMessageForMouseNSEvent no longer matches the 6-arg signature
  • sendWithMessage: is NOT called by Simulator.app when the user clicks — it's for external API only

Approach 3: CGEvent on Simulator.app Window

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.

Approach 4: Direct simDigitizerInputView:touchEvent: Call

Result: Failed. lldb tracing with SIP disabled revealed the actual touch delivery path inside Simulator.app:

  1. User clicks in Simulator.app window
  2. SimDigitizerInputView processes the NSEvent
  3. Calls simDigitizerInputView(_:touchEvent:) on SimDeviceLegacyHIDClient
  4. 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.

Approach 5: In-App Hammer Approach (SOLVED)

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:

  1. dlopen IOKit + BackBoardServices in the simulator runtime
  2. IOHIDEventCreateDigitizerFingerEvent() — create touch event (transducerType=3/hand)
  3. IOHIDEventSetIntegerValue(event, 0xB0019, 1) — isDisplayIntegrated flag
  4. IOHIDEventSetSenderID(event, nonzero) — required sender ID
  5. BKSHIDEventSetDigitizerInfo(event, contextId, ...) — route to window via UIWindow._contextId
  6. UIApplication._enqueueHIDEvent(event) — deliver to app event loop

Key details:

  • BKSHIDEventSetDigitizerInfo is in BackBoardServices.framework (available in simulator runtime)
  • UIApplication._enqueueHIDEvent: exists and works without entitlements
  • UIWindow._contextId provides 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.

External References


Investigation conducted March 2026. Xcode 26.2 (17C49), macOS 26.2, Swift 6.2.3, Apple Silicon.