React Native macOS apps

A React Native macOS app is a Squiggle target like a simulator app. The graph works with no native code at all. A small agent you load in Debug builds adds drags, screenshots and settle-gated waits, delivered inside the app so it can stay in the background while you keep working.

What works with no native code

The graph is built from the React tree in JavaScript, and element rects come from Fabric’s layout code, which is shared across platforms. So with only the SDK in the app, most of Squiggle already works:

Reading the app
describe, find, query and the route; the component tree, props and render counts; element rects.
Taps and typing
Through the runtime driver, which acts on the element inside JavaScript rather than through a pointer.
Programmatic scrolling
The driver calls the scroll view’s own scrollTo. There is no gesture and no momentum, so this gets the app to a state; it does not test scrolling.
Assertions and the rest of the graph tooling
assert and graph-based waitFor, legibility, record, replay and crawl.
JavaScript performance
The JS collectors, spans and perf sql, plus CPU profiles and heap snapshots over Metro.

Without the agent, drags and swipes answer unsupported, and there are no animation-idle or settle-gated waits, no network capture or stubs, no log assertions, and no screenshots or frame hashes.

Setup

1 · The SDK and the platform

Add the SDK as Getting started describes, without the Expo config plugin: React Native macOS apps are not built with expo prebuild. The SDK’s native module includes a macOS build, for macOS 14 and later.

Then tell Squiggle the app runs on macOS. squiggle init records "platform": "macos" in .squiggle/config.jsonc when the app depends on react-native-macos and has no ios/ directory. If it targets both, init leaves the choice to you: set the key yourself, or pass --platform macos.

2 · Put the agent in your Debug build

On a simulator the agent is injected at launch. That cannot work here: the macOS loader strips injected libraries from a hardened or sandboxed process, and the React Native macOS template is one. So the app carries the agent and loads it itself. However you start the app, from the Dock, Finder or Xcode, the agent is there, because it is inside the app. A script does the part that is safe to script. It ships beside the squiggle binary, in the npm package and in Squiggle.app/Contents/Resources:

Terminal
# the script ships beside the squiggle binary;# in the desktop app that is Squiggle.app/Contents/Resources/install-macos-agent.sh macos/MyApp-macOS

It copies libsquiggleagent-mac.dylib into a Squiggle/ folder in your macOS target, then prints the two Xcode steps it leaves to you, because editing a project file from a shell script is how project files get corrupted:

A Copy Files build phase
On the macOS app target: destination Frameworks, the dylib added, and Code Sign On Copy ticked, so Xcode signs it with the app’s own identity.
A dlopen in #if DEBUG
In the AppDelegate. It loads the bundled copy, unless SQUIGGLE_AGENT_DYLIB names another one.
AppDelegate.mm
#if DEBUG#import <dlfcn.h>// …inside applicationDidFinishLaunching:{  NSString *override = NSProcessInfo.processInfo.environment[@"SQUIGGLE_AGENT_DYLIB"];  NSString *bundled = [[NSBundle mainBundle] pathForResource:@"libsquiggleagent-mac"                                                      ofType:@"dylib"                                                 inDirectory:@"../Frameworks"];  NSString *path = override.length ? override : bundled;  if (path && !dlopen(path.fileSystemRepresentation, RTLD_NOW)) {    NSLog(@"[squiggle] agent dylib failed to load: %s", dlerror());  }}#endif

When squiggle up launches the app, it sets SQUIGGLE_AGENT_DYLIB to the agent beside the CLI, so that launch always runs the agent matching the CLI. A launch from the Dock, Finder or Xcode runs the bundled copy. A Release build contains none of this.

3 · Bring it up

Terminal
squiggle up com.example.myapp --platform macos

On a Mac the bundle id is the whole target, so there is no device to pick. The app has to be one macOS can find by its bundle id, so build it in Debug first. up launches it without bringing it to the front, and a relaunch waits for the old process to exit before starting the new one. It then asks the running app which agent it loaded and prints that dylib’s Mach-O UUID, which is the proof the agent is in. Over MCP, launch_app does the same.

What the agent adds

Every gesture is delivered inside the app’s own process. Rather than posting a mouse event, the agent calls the gesture handler the way AppKit would. The app can be behind other windows, hidden, minimised or on another Space. Your cursor does not move, the app is not activated, and no Accessibility permission is needed.

Press
Gesture Handler buttons, through their own handler, and plain Pressables, through React Native’s touch pipeline.
Drag and pinch
A drag drives the Pan handler under the start point, with translation and velocity fed on every step, so a reorder, a slider or a resize handle moves as it would under a finger. Pinch drives a Pinch handler the same way.
Scroll
Moves the scroll view’s content directly. It is deterministic and has no momentum.
Typing
Focuses the text field at the point and inserts the text through the field’s own text-input interface, without taking focus from whatever app you are using.
Screenshots
Taken from the window server for the app’s own window, which needs no Screen Recording permission and includes Skia and other Metal content.
Waits and capture
Animation-idle and settle-gated waits, log assertions, and network capture and stubs when the app is launched with --capture-net.

Agents pick the app themselves

The MCP server needs no configuration to find a macOS app. Every app with the agent loaded, simulator or Mac, leaves one agent socket, so the server finds all of them with one directory read. It drives the one you brought up most recently, so switching targets is a command (squiggle up, launch_app) rather than a setting. A running server notices a newer app on its next tool call, without a restart. When several apps are up, the target tool lists them and switches by bundle id or by the word macos, and it refuses an ambiguous match instead of guessing. An app without the agent is still reachable over its graph session. See Agents & MCP.

Limits

Release builds are refused
An app signed with the hardened runtime and without the get-task-allow entitlement cannot load the agent, so squiggle up refuses it by name, before launching, with E_NO_INJECT_MACOS. The check reads the app’s signature. If codesign is not installed, the check is skipped rather than treated as a refusal. Launching without the agent works against any app.
Controls React Native does not own
A press needs a Gesture Handler handler or React Native’s touch pipeline at the point. A plain AppKit control has neither, so a tap on one fails.
No back gesture
macOS has none, so back answers unsupported. Use navigate with a route.
No video recording
squiggle flow --record prints that screen recording is a simulator feature and records nothing. Session recordings (.sqrec) work.
Screenshots of a window with no current frame
A window that is minimised, or was resized while covered, has no up-to-date frame for the window server to hand back. The agent then redraws the view tree itself, which misses Metal content (every Skia surface) and masked layers. The reply’s via says which path produced the image.
Frame rate
On macOS the SDK’s frame clock follows the main screen, which may not be the display the window is on.
No Studio canvas
Studio’s live canvas mirrors simulators. A macOS app is driven from the CLI and MCP.

Probing from outside the app

squiggle probe attaches to Metro’s inspector, so it needs no agent and no rebuild. It reaches both the JavaScript runtime and the Reanimated UI runtime. It counts renders per component (with the SDK’s profiler in a development build), counts live animations, finds setInterval timers fast enough to busy-loop on React Native macOS, and measures CPU over a fixed window. Point --port at your Metro server. On a macOS target, squiggle doctor also scans your source for those intervals. Every leaf is documented under The CLI.