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,queryand 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
assertand graph-basedwaitFor,legibility,record,replayandcrawl.- 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:
# the script ships beside the squiggle binary;# in the desktop app that is Squiggle.app/Contents/Resources/install-macos-agent.sh macos/MyApp-macOSIt 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
dlopenin#if DEBUG - In the AppDelegate. It loads the bundled copy, unless
SQUIGGLE_AGENT_DYLIBnames another one.
#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()); }}#endifWhen 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
squiggle up com.example.myapp --platform macosOn 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-allowentitlement cannot load the agent, sosquiggle uprefuses it by name, before launching, withE_NO_INJECT_MACOS. The check reads the app’s signature. Ifcodesignis 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
backanswersunsupported. Usenavigatewith a route. - No video recording
squiggle flow --recordprints 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
viasays 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.