# React Native macOS apps

Squiggle drives React Native macOS apps as well as simulator apps. The SDK reads your app and taps and types in it, and in Debug builds it also loads a small agent that adds drags, screenshots and the waits that make flows fast, all inside the app, so it can stay in the background while you work.

## What works with only the SDK

Add the SDK and most of Squiggle works, with no native code:

- **Reading the app**: `describe`, `find`, `query` and the current screen; the component tree, props and render counts; where each element is.
- **Taps, hover and typing**: Acted on the element directly, with no pointer. A hover runs the element’s `onHoverIn` or `onMouseEnter`.
- **Scrolling**: Moves the list straight to a position, with no gesture and no momentum. It gets the app to a state; it doesn’t test scrolling.
- **Assertions and the graph tools**: `assert` and `waitFor` on the graph, `legibility`, `record`, `replay` and `crawl`.
- **JavaScript performance**: Renders, spans and `perf sql`, plus CPU profiles and heap snapshots over Metro.

Without the agent, drags and swipes report `unsupported`, and there are no animation or settle waits, no network capture or stubs, no log assertions and no screenshots.

> **Needs the New Architecture**
>
> On the old architecture, Squiggle can’t tell where anything is on screen, and nothing reports that as an error.

## Set it up

### 1 · Add the SDK

Add the SDK as [Set up your app](https://squiggle.sh/docs/setup) describes, without the Expo config plugin: React Native macOS apps aren’t built with `expo prebuild`. The SDK supports macOS 15 and later, so in `macos/Podfile` set `platform :macos, '15.0'`, then run `pod install` in `macos/`.

`squiggle init` records your app’s bundle id for each platform it builds for in `.squiggle/config.jsonc`, as `apps`. It also sets `"platform": "macos"` when the app has no `ios/` folder.

### 2 · Build the app in Debug

There is nothing to add for the agent. In a Debug build, the SDK loads it when the app starts, however you start it: from the Dock, Finder or Xcode. A build you sign for distribution, with Developer ID or for the App Store, never loads it, and nothing of the agent is copied into your app.

A Debug build started that way loads the copy of the agent in `~/.squiggle/agent`. `squiggle init` puts it there, and every `squiggle up` replaces it with the version that came with your `squiggle` command.

> **Set up with an older version of Squiggle?**
>
> If you added a Copy Files build phase for `libsquiggleagent-mac.dylib` and a `dlopen` to your AppDelegate, remove both and rebuild. The app still works with them, but it loads the copy inside the app, which stays at the version you installed, so `status` reports `dylib: stale` after you update Squiggle.

> **If your Debug build uses App Sandbox**
>
> A sandboxed app can only read its own folder, so it can’t see the copy in `~/.squiggle/agent`. Run `squiggle up` once: it puts the agent inside the app’s own folder, and from then on the app loads it however you start it. The first time, macOS may ask whether your terminal (or Studio) can access data from other apps. Allow it, or the agent can’t be copied in.

### 3 · Launch it

```shell
squiggle up
```

There is no device to pick: the bundle id is the whole target, and `init` recorded it. It launches the app **without bringing it to the front**, with the agent that came with your `squiggle` command, and a relaunch waits for the old one to quit. It then prints which agent the app loaded, so you know the agent is in. Over MCP, `launch_app` does the same.

If your app also has an `ios/` folder, add `--platform macos`: Squiggle takes the macOS app from `apps`. If `init` couldn’t read the bundle id, name the app too:

```shell
squiggle up com.example.myapp --platform macos
```

## What the agent adds

The agent works inside the app, not through your mouse and keyboard. The app can be behind other windows, hidden, minimised or on another Space. Your cursor doesn’t move, the app doesn’t come to the front, and macOS asks for no Accessibility permission.

- **Press**: Gesture Handler buttons and plain `Pressable`s.
- **Drag and pinch**: A drag moves like a finger, so a reorder, a slider or a resize handle responds as it would on a phone. Pinch works the same way.
- **Scroll**: Moves the content directly, with no momentum.
- **Typing**: Into the field at the point, without taking focus from the app you are using.
- **Hover**: At the point, as a real pointer would, so every hover card, tooltip and highlight under it appears, including on views no component of yours owns. Your agent’s `hover` tool does it, and `leave: true` moves off again.
- **Screenshots**: Of the app’s own window, including Skia and other Metal content, with no Screen Recording permission.
- **Waits and capture**: Animation and settle waits, log assertions, and network capture and stubs when the app is launched with `--capture-net`.

> **A miss fails**
>
> If nothing at the point handles the gesture, the step fails with a reason such as `no-handler-at-point`. Squiggle never falls back to a real mouse click, which would take your cursor, bring the app to the front, and could report success for a click that landed somewhere else.

## Record a video of a run

Add `--record` to a run, or `record: true` to your agent’s `run_flow`:

```shell
squiggle flow checkout --record
```

Squiggle films the app’s main window at its own size and saves an `.mp4` in `.squiggle/videos`, the same as a simulator run, so `filmstrip` reads it the same way. Only that window is filmed: windows in front of it never appear in the video, and you can keep working while it records.

Filming another app’s window needs the **Screen Recording** permission for the app that runs `squiggle`: your terminal, your editor, or Studio. Turn it on in System Settings, under Privacy & Security. Without it the run goes ahead and warns that the recording couldn’t start.

macOS only gives Squiggle new frames while it is drawing the window. While your display sleeps, your screen is locked, or the window is minimised, the video holds the last frame. If the window never drew during the run, there is no video, and the run says “no frames were captured”.

## Choose the app your agent drives

Your agent finds macOS apps with no configuration, and drives the one brought up most recently, so you switch apps with `squiggle up` or `launch_app`. A running server notices a newly launched app without a restart. When several are running, the `target` tool lists them and switches by bundle id, or by the word `macos`; it refuses a name that matches more than one. An app without the agent can still be read and tapped through its SDK. See [Connect your agent](https://squiggle.sh/docs/agents).

The iOS and macOS builds of one app can run at the same time, each with its own agent. A run aimed at one never connects to the other, and a flow waiting for the app’s own runs to finish only waits for runs on its own device. Pick the platform with `--platform` on the command line, or `target` with `ios` or `macos` in your agent; the app comes from `apps`. `launch_app` refuses a bundle id that is running on both.

Each platform keeps its own results in the one `.squiggle` folder. Runs, bench history, performance sessions and what your agent learns are recorded with the platform they ran on. A bench run is only measured against earlier runs on the same platform, a learned flow that passes on iOS still counts as unproven on macOS, and `perf diff` refuses to compare a session with a baseline from the other platform. Product reports read one platform at a time.

## Limits

- **Release builds are refused**: An app signed for release (with the hardened runtime and without the `get-task-allow` entitlement) can’t load the agent, so `squiggle up` refuses it before launching, with `E_NO_INJECT_MACOS`. Launching without the agent works for any app.
- **Controls React Native doesn’t own**: A plain AppKit control isn’t driven by React Native, so a tap on one fails.
- **No back gesture**: macOS has none, so `back` reports `unsupported`. Use `navigate` with a route.
- **Screenshots of a minimised window**: A minimised window, or one resized while covered, has no current picture, so the agent draws the window again itself. That copy leaves out Skia and other Metal content, and masked layers. The reply’s `via` field says which way the image was made.
- **Frame rate**: The frame rate follows your main display, which may not be the one the window is on.
- **No JS frame rate**: On React Native macOS 0.81, `requestAnimationFrame` calls back as fast as the JS thread can run instead of once per frame, so Performance shows no JS FPS for a Mac app. Animation loops in your app built on `requestAnimationFrame` run that fast too, and keep the JS thread busy.
- **No mirror in Studio**: Studio lists your running Mac app in its device menu, marked **(Mac)**. Select it and flows, Performance, Product and Analytics follow it, but there is no live mirror: its window is the app’s own.

## Measure from outside the app

`squiggle probe` connects through Metro, so it needs no agent and no rebuild. It counts renders per component (in a development build), counts running animations, including on the Reanimated UI thread, finds `setInterval` timers fast enough to keep React Native macOS busy, and measures CPU over a fixed window. Point `--port` at your Metro server. On a macOS target, `squiggle doctor` also checks your source for those timers. See the [CLI reference](https://squiggle.sh/docs/reference/cli).
